<?php
/**
 * Agent Loop — the main while(true) loop for Niwa AI.
 *
 * PHP equivalent of Pi agent-core's `runLoop()` function from agent-loop.ts.
 * Implements a dual-loop architecture:
 *
 *   OUTER while(true):
 *     INNER while(has_more_tool_calls || has_pending_messages):
 *       - Process steering messages
 *       - Stream assistant response from LLM
 *       - Execute tool calls inline
 *     Check for follow-up messages
 *     If none -> break outer loop
 *
 * This replaces the old Turn Engine's linear for-loop approach with a truly
 * unbounded agent loop (capped only by MAX_ITERATIONS for safety).
 *
 * @package Niwa_AI
 * @since   0.12.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class Niwa_AI_Agent_Loop {

	/* Constants */

	/** Maximum total iterations across all loops — safety cap (like OpenClaw). */
	const MAX_ITERATIONS = 300;

	/** Maximum times the same tool name can be called in a single agent run. */
	const MAX_SAME_TOOL_CALLS = 600;

	/** Maximum times the exact same tool call can repeat in a single agent run. */
	const EXACT_TOOL_CALL_LIMIT = 300;

	/** Maximum read-only main-coding shell calls before forcing a stop/report. */
	const MAIN_CODING_READ_ONLY_SHELL_LIMIT = 80;

	/** Soft read budget tracker. Counter only — does not block (kept for telemetry). */
	const MAIN_CODING_READ_ONLY_TOOL_NUDGE_LIMIT = 25;

	/** Maximum combined read-only coding tool calls before forcing a strategy change. */
	const MAIN_CODING_READ_ONLY_TOOL_LIMIT = 60;

	/** Maximum read-only/non-write steps after workspace_prepare before stopping. */
	const MAIN_CODING_POST_PREPARE_STEP_LIMIT = 30;

	/** Soft post-prepare tracker. Counter only — does not block (kept for telemetry). */
	const MAIN_CODING_POST_PREPARE_NUDGE_STEP_LIMIT = 18;

	/** Maximum non-verification steps after the first main-coding work-copy write. */
	const MAIN_CODING_POST_WRITE_STEP_LIMIT = 50;

	/** Per-tool overrides for tools that should be capped tighter than the default. */
	const TIGHT_TOOL_CAPS = array(
		'companion_memory_capture' => 1,
		'companion_memory_search' => 4,
		'agent_memory_search' => 4,
		'agent_memory_save' => 3,
		'woo_product_lookup' => 8,
		'woo_variation_lookup' => 8,
	);

	/** Default per-API-call timeout in seconds. */
	const DEFAULT_TIMEOUT = 600;

	/** Maximum total model request timeout while running under LiteSpeed/LSAPI. */
	const LITESPEED_MODEL_REQUEST_TIMEOUT = 240;

	/** Seconds to leave after a model request for checkpoint/recovery work. */
	const MODEL_REQUEST_SOFT_DEADLINE_BUFFER = 30;

	/** Minimum seconds needed before starting/retrying another model call. */
	const MODEL_REQUEST_CHECKPOINT_SECONDS = 90;

	/** Maximum consecutive stream errors before aborting. */
	const MAX_STREAM_RETRIES = 15;

	/** Seconds before soft deadline when the agent receives a runtime warning. */
	const SOFT_DEADLINE_WARNING_SECONDS = 55;

	/** Maximum compaction attempts within a single loop run. */
	const MAX_COMPACTION_ATTEMPTS = 3;

	/** Maximum characters for the system prompt / bootstrap context. */
	const BOOTSTRAP_MAX_CHARS = 24000;

	/** Maximum raw tool-result bytes kept in the next LLM turn before semantic compaction. */
	const TOOL_OUTPUT_COMPACT_THRESHOLD = 60000;

	/** Maximum string length inside compacted tool-result JSON. */
	const TOOL_OUTPUT_COMPACT_STRING_LIMIT = 8000;

	/* Dependencies */

	/**
	 * AI client for non-streaming fallback and compaction summarization.
	 *
	 * @var Niwa_AI_AI_Client_Interface
	 */
	private $ai_client;

	/**
	 * Event emitter for lifecycle events.
	 *
	 * @var Niwa_AI_Agent_Event_Emitter
	 */
	private $event_emitter;

	/**
	 * Error recovery / retry strategy.
	 *
	 * @var Niwa_AI_Error_Recovery
	 */
	private $error_recovery;

	/**
	 * Context window guard — token estimation and compaction.
	 *
	 * @var Niwa_AI_Context_Window_Guard
	 */
	private $context_guard;

	/**
	 * Message validator — ensures well-formed API messages.
	 *
	 * @var Niwa_AI_Message_Validator
	 */
	private $message_validator;

	/* Callbacks */

	/**
	 * Callable that returns true when the agent should stop.
	 *
	 * @var callable|null
	 */
	private $cancel_probe;

	/**
	 * Legacy progress callback for backward compatibility with old Turn Engine consumers.
	 *
	 * @var callable|null
	 */
	private $progress_callback;

	/**
	 * Called before each tool call execution.
	 * Signature: function( array $tool_call, Niwa_AI_Agent_Context $context ): void
	 *
	 * @var callable|null
	 */
	private $before_tool_call;

	/**
	 * Called after each tool call execution.
	 * Signature: function( array $tool_call, array $result, Niwa_AI_Agent_Context $context ): void
	 *
	 * @var callable|null
	 */
	private $after_tool_call;

	/**
	 * Returns steering messages to inject mid-loop.
	 * Signature: function(): array  — returns array of message arrays.
	 *
	 * @var callable|null
	 */
	private $steering_messages_provider;

	/**
	 * Returns follow-up messages after inner loop completes.
	 * Signature: function(): array  — returns array of message arrays.
	 *
	 * @var callable|null
	 */
	private $follow_up_messages_provider;

	/**
	 * Runtime iteration cap for this loop instance.
	 *
	 * @var int
	 */
	private $max_iterations = self::MAX_ITERATIONS;

	/* Constructor */

	/**
	 * Create a new agent loop.
	 *
	 * @param Niwa_AI_AI_Client_Interface $ai_client AI client instance for API calls.
	 * @param array                       $options   {
	 *     Optional configuration.
	 *
	 *     @type Niwa_AI_Agent_Event_Emitter|null   $event_emitter              Event emitter (created if null).
	 *     @type Niwa_AI_Error_Recovery|null         $error_recovery             Error recovery (created if null).
	 *     @type Niwa_AI_Context_Window_Guard|null   $context_guard              Context guard (created if null).
	 *     @type Niwa_AI_Message_Validator|null       $message_validator          Message validator (created if null).
	 *     @type callable|null                       $cancel_probe               Returns truthy when loop should stop.
	 *     @type callable|null                       $progress_callback          Legacy progress callback.
	 *     @type callable|null                       $before_tool_call           Hook before tool execution.
	 *     @type callable|null                       $after_tool_call            Hook after tool execution.
	 *     @type callable|null                       $steering_messages_provider  Mid-loop message provider.
	 *     @type callable|null                       $follow_up_messages_provider Post-inner-loop message provider.
	 *     @type int|null                            $max_iterations             Runtime iteration cap for this loop.
	 * }
	 */
	public function __construct( Niwa_AI_AI_Client_Interface $ai_client, $options = array() ) {
		$this->ai_client = $ai_client;

		if ( ! is_array( $options ) ) {
			$options = array();
		}

		// Dependency injection with sensible defaults.
		$this->event_emitter = isset( $options['event_emitter'] ) && $options['event_emitter'] instanceof Niwa_AI_Agent_Event_Emitter
			? $options['event_emitter']
			: Niwa_AI_Agent_Event_Emitter::create();

		$this->error_recovery = isset( $options['error_recovery'] ) && $options['error_recovery'] instanceof Niwa_AI_Error_Recovery
			? $options['error_recovery']
			: new Niwa_AI_Error_Recovery();

		$this->context_guard = isset( $options['context_guard'] ) && $options['context_guard'] instanceof Niwa_AI_Context_Window_Guard
			? $options['context_guard']
			: new Niwa_AI_Context_Window_Guard();

		$this->message_validator = isset( $options['message_validator'] ) && $options['message_validator'] instanceof Niwa_AI_Message_Validator
			? $options['message_validator']
			: new Niwa_AI_Message_Validator();

		// Callbacks.
		$this->cancel_probe               = isset( $options['cancel_probe'] ) && is_callable( $options['cancel_probe'] ) ? $options['cancel_probe'] : null;
		$this->progress_callback          = isset( $options['progress_callback'] ) && is_callable( $options['progress_callback'] ) ? $options['progress_callback'] : null;
		$this->before_tool_call           = isset( $options['before_tool_call'] ) && is_callable( $options['before_tool_call'] ) ? $options['before_tool_call'] : null;
		$this->after_tool_call            = isset( $options['after_tool_call'] ) && is_callable( $options['after_tool_call'] ) ? $options['after_tool_call'] : null;
		$this->steering_messages_provider = isset( $options['steering_messages_provider'] ) && is_callable( $options['steering_messages_provider'] ) ? $options['steering_messages_provider'] : null;
		$this->follow_up_messages_provider = isset( $options['follow_up_messages_provider'] ) && is_callable( $options['follow_up_messages_provider'] ) ? $options['follow_up_messages_provider'] : null;
		$this->max_iterations             = isset( $options['max_iterations'] ) ? max( 1, intval( $options['max_iterations'] ) ) : self::MAX_ITERATIONS;

	}

	/* Main public API */

	/**
	 * Run the agent loop.
	 *
	 * Signature-compatible with the old Turn Engine's `run()` method so that
	 * callers (Niwa_AI_Admin_Autonomy_Chat::respond, subagent manager, etc.)
	 * can switch between the two without changes.
	 *
	 * @param object $bridge  Bridge object with `build_turn_runtime()` and
	 *                        `execute_turn_tool_calls()` methods.
	 * @param string $prompt  The user's prompt / message.
	 * @param array  $context Contextual data (session_id, history, etc.).
	 * @param array  $options Turn-level options (mode, cancel_probe, progress_callback, etc.).
	 *
	 * @return array {
	 *     @type bool   $success    Whether the loop completed successfully.
	 *     @type string $reply      The final assistant answer.
	 *     @type string $model      Model identifier used.
	 *     @type string $turn_id    Turn ID from session state.
	 *     @type int    $iterations Total number of inner-loop iterations.
	 *     @type array  $usage      Aggregated token usage {prompt_tokens, completion_tokens, total_tokens}.
	 *     @type string $error      Error message (present when success is false).
	 *     @type bool   $cancelled  True when loop was cancelled via cancel_probe.
	 * }
	 */
	public function run( $bridge, $prompt, $context = array(), $options = array() ) {
		$prompt  = trim( (string) $prompt );
		$context = is_array( $context ) ? $context : array();
		$options = is_array( $options ) ? $options : array();
		// Allow per-run override of cancel_probe and progress_callback.
		$run_cancel_probe = isset( $options['cancel_probe'] ) && is_callable( $options['cancel_probe'] )
			? $options['cancel_probe']
			: $this->cancel_probe;

		$run_progress_callback = isset( $options['progress_callback'] ) && is_callable( $options['progress_callback'] )
			? $options['progress_callback']
			: $this->progress_callback;

		$soft_deadline = isset( $options['soft_deadline'] ) ? intval( $options['soft_deadline'] ) : 0;
		if ( $soft_deadline <= 0 && isset( $context['soft_deadline'] ) ) {
			$soft_deadline = intval( $context['soft_deadline'] );
			if ( $soft_deadline > 0 ) {
				$options['soft_deadline'] = $soft_deadline;
			}
		}

		// Validation
		if ( '' === $prompt ) {
			return array(
				'success' => false,
				'error'   => 'Prazna poruka.',
			);
		}

		if ( ! is_object( $bridge ) || ! method_exists( $bridge, 'build_turn_runtime' ) || ! method_exists( $bridge, 'execute_turn_tool_calls' ) ) {
			return array(
				'success' => false,
				'error'   => 'Turn bridge nije validan.',
			);
		}

		// Build runtime from bridge
		$mode    = isset( $options['mode'] ) ? sanitize_key( (string) $options['mode'] ) : 'main';
		$runtime = $bridge->build_turn_runtime( $prompt, $context, $mode, $options );

		if ( empty( $runtime['success'] ) ) {
			$error = isset( $runtime['error'] ) ? (string) $runtime['error'] : 'Turn runtime nije spreman.';
			return array(
				'success' => false,
				'error'   => $error,
			);
		}

		// Extract runtime parameters
		$model        = isset( $runtime['model'] ) ? (string) $runtime['model'] : 'gpt-5.4-mini';
		$auth_mode    = isset( $runtime['auth_mode'] ) ? (string) $runtime['auth_mode'] : 'api_key';
		$creds        = isset( $runtime['creds'] ) && is_array( $runtime['creds'] ) ? $runtime['creds'] : array();
		$instructions = isset( $runtime['instructions'] ) ? (string) $runtime['instructions'] : '';
		$tools        = isset( $runtime['tools'] ) && is_array( $runtime['tools'] ) ? $runtime['tools'] : array();
		$tool_choice  = isset( $runtime['tool_choice'] ) ? $runtime['tool_choice'] : null;
		$previous_response_id = isset( $runtime['previous_response_id'] ) ? trim( (string) $runtime['previous_response_id'] ) : '';
		$messages     = isset( $runtime['messages'] ) && is_array( $runtime['messages'] ) ? $runtime['messages'] : array(
			array( 'role' => 'system', 'content' => $instructions ),
			array( 'role' => 'user', 'content' => $prompt ),
		);
		$messages     = Niwa_AI_Agent_Message_Utils::strip_duplicate_runtime_system_message( $messages, $instructions );
		$runtime_objective = $runtime['turn_objective'] ?? ( $runtime['input'] ?? $prompt );
		$turn_objective = class_exists( 'Niwa_AI_Admin_Autonomy_Task_Integrity' )
			? Niwa_AI_Admin_Autonomy_Task_Integrity::canonicalize_content( $runtime_objective )
			: ( is_string( $runtime_objective ) ? trim( $runtime_objective ) : trim( (string) wp_json_encode( $runtime_objective ) ) );
		if ( '' === $turn_objective ) {
			$turn_objective = (string) $prompt;
		}
		$turn_frame = class_exists( 'Niwa_AI_Admin_Autonomy_Task_Integrity' )
			? Niwa_AI_Admin_Autonomy_Task_Integrity::resolve_turn_frame(
				isset( $runtime['turn_frame'] ) && is_array( $runtime['turn_frame'] ) ? $runtime['turn_frame'] : array(),
				$messages,
				$turn_objective,
				$prompt
			)
			: array(
				'current_user_prompt' => (string) $prompt,
				'turn_objective'      => $turn_objective,
				'previous_dialogue'   => array(),
			);
		$turn_id      = isset( $options['turn_id'] ) && '' !== trim( (string) $options['turn_id'] )
			? trim( (string) $options['turn_id'] )
			: wp_generate_uuid4();
		$options['turn_id'] = $turn_id;
		$context['turn_id'] = $turn_id;
		$model_request_timeout = $this->resolve_model_request_timeout( $options );

		// Build the Agent Context
		$agent_context = new Niwa_AI_Agent_Context( array(
			'system_prompt' => $instructions,
			'messages'      => $messages,
			'tools'         => $tools,
			'model'         => $model,
			'previous_response_id' => $previous_response_id,
			'auth_mode'     => $auth_mode,
			'creds'         => $creds,
			'turn_frame'    => $turn_frame,
			'tool_evidence' => isset( $runtime['tool_evidence'] ) && is_array( $runtime['tool_evidence'] ) ? $runtime['tool_evidence'] : array(),
			'execution_provenance' => isset( $runtime['execution_provenance'] ) && is_array( $runtime['execution_provenance'] ) ? $runtime['execution_provenance'] : array(),
			'api_options'   => array(
				'tool_choice'         => $tool_choice,
				'request_timeout'     => $model_request_timeout,
				'model_request_debug' => array(
					'turn_id'           => $turn_id,
					'mode'              => $mode,
					'job_id'            => isset( $options['job_id'] ) ? (string) $options['job_id'] : '',
					'parent_session_id' => isset( $options['parent_session_id'] ) ? (string) $options['parent_session_id'] : '',
					'parent_turn_id'    => isset( $options['parent_turn_id'] ) ? (string) $options['parent_turn_id'] : '',
					'allow_writes'      => 'main' === $mode ? true : ! empty( $options['allow_writes'] ),
				),
			),
		) );

		// Tracking state
		$iteration_count     = 0;
		$compaction_attempts = 0;
		$total_usage         = array(
			'prompt_tokens'     => 0,
			'completion_tokens' => 0,
			'total_tokens'      => 0,
			'cached_tokens'     => 0,
			'cache_hit_rate'    => 0.0,
		);
		$final_answer        = '';
		$tool_call_counts    = array(); // Loop guard: tracks repeated calls.
		$stream_error_attempts = 0;
		$pseudo_tool_call_retries = 0;
		$soft_deadline_warning_sent = false;

		// Check for early cancellation
		if ( $this->is_cancelled( $run_cancel_probe ) ) {
			$cancelled_reason = ( $soft_deadline > 0 && time() >= $soft_deadline ) ? 'soft_deadline' : 'external';
			return array(
				'success'          => false,
				'cancelled'        => true,
				'cancelled_reason' => $cancelled_reason,
				'error'            => 'Agent cancelled before start.',
				'reply'            => '',
				'turn_id'          => $turn_id,
			);
		}

		// Emit agent_start
		$this->emit(
			Niwa_AI_Agent_Event_Emitter::EVENT_AGENT_START,
			array(
				'turn_id' => $turn_id,
				'model'   => $model,
				'mode'    => $mode,
			),
			$run_progress_callback
		);

		while ( true ) {
			$has_more_tool_calls = true;
			$has_pending_messages = false;

			while ( $has_more_tool_calls || $has_pending_messages ) {

				// Safety cap.
				$iteration_count++;
				if ( $iteration_count > $this->max_iterations ) {
					$this->emit(
						Niwa_AI_Agent_Event_Emitter::EVENT_ERROR,
						array(
							'message' => 'Max iterations reached (' . $this->max_iterations . ').',
							'fatal'   => true,
						),
						$run_progress_callback
					);

					$final_answer = Niwa_AI_Agent_Message_Utils::extract_last_assistant_content( $agent_context );
					break 2; // Break both loops.
				}

				// Direct soft-deadline check at top of every iteration. The
				// turn-engine cancel_probe also checks the deadline, but a
				// second inline check guarantees we exit cleanly with a
				// continuation snapshot even if the cancel_probe is not
				// honored on this code path (e.g. inside a long-running
				// model_request that returns to the loop without the probe
				// firing). Critical on LSAPI/LiteSpeed where the SAPI hard
				// kill is ~480s and we must checkpoint well before it.
				if ( $soft_deadline > 0 && time() >= $soft_deadline ) {
					$this->emit(
						Niwa_AI_Agent_Event_Emitter::EVENT_AGENT_END,
						array(
							'turn_id'          => $turn_id,
							'reason'           => 'cancelled',
							'cancelled_reason' => 'soft_deadline',
							'iterations'       => $iteration_count,
						),
						$run_progress_callback
					);

					return array(
						'success'          => false,
						'cancelled'        => true,
						'cancelled_reason' => 'soft_deadline',
						'error'            => 'Agent reached soft deadline before next iteration.',
						'reply'            => '',
						'turn_id'          => $turn_id,
						'iterations'       => $iteration_count,
						'usage'            => $total_usage,
						'context_snapshot' => $agent_context->serialize_for_continuation(),
					);
				}

				// Check cancellation.
				if ( $this->is_cancelled( $run_cancel_probe ) ) {
					$cancelled_reason = ( $soft_deadline > 0 && time() >= $soft_deadline ) ? 'soft_deadline' : 'external';
					$this->emit(
						Niwa_AI_Agent_Event_Emitter::EVENT_AGENT_END,
						array(
							'turn_id'          => $turn_id,
							'reason'            => 'cancelled',
							'cancelled_reason' => $cancelled_reason,
							'iterations'       => $iteration_count,
						),
						$run_progress_callback
					);

					$cancel_result = array(
						'success'          => false,
						'cancelled'        => true,
						'cancelled_reason' => $cancelled_reason,
						'error'            => 'Agent cancelled during execution.',
						'reply'            => '',
						'turn_id'          => $turn_id,
						'iterations'       => $iteration_count,
						'usage'            => $total_usage,
					);

					if ( 'soft_deadline' === $cancelled_reason ) {
						$cancel_result['context_snapshot'] = $agent_context->serialize_for_continuation();
					}

					return $cancel_result;
				}

				$this->maybe_inject_soft_deadline_warning(
					$agent_context,
					$soft_deadline,
					$soft_deadline_warning_sent,
					$turn_id,
					$run_progress_callback
				);

				if ( $this->should_checkpoint_before_model_request( $options ) ) {
					$checkpoint_result = $this->build_soft_deadline_checkpoint_result(
						$agent_context,
						$context,
						$turn_id,
						$model,
						$iteration_count,
						$total_usage,
						'Agent checkpointed before starting another model request near the soft deadline.',
						$run_progress_callback
					);
					return $checkpoint_result;
				}

				// Emit turn_start
				$this->emit(
					Niwa_AI_Agent_Event_Emitter::EVENT_TURN_START,
					array(
						'turn_id'   => $turn_id,
						'iteration' => $iteration_count,
						'model'     => $model,
					),
					$run_progress_callback
				);

				// Process steering messages
				$has_pending_messages = false;
				$steering_messages    = $this->get_steering_messages();

				if ( ! empty( $steering_messages ) ) {
					$this->inject_messages( $steering_messages, $agent_context );
				}

				// Stream assistant response
				$current_model_request_timeout = $this->resolve_model_request_timeout( $options );
				$current_api_options           = $agent_context->get_api_options();
				$current_api_options['request_timeout'] = $current_model_request_timeout;
				if ( isset( $current_api_options['model_request_debug'] ) && is_array( $current_api_options['model_request_debug'] ) ) {
					$current_api_options['model_request_debug']['request_timeout'] = $current_model_request_timeout;
				}
				$agent_context->set_api_options( $current_api_options );

				$stream_result = $this->stream_assistant_response( $agent_context, $run_progress_callback );

				if ( ! $stream_result['success'] ) {
					// Error recovery
					$error_message = isset( $stream_result['error'] ) ? (string) $stream_result['error'] : 'Unknown stream error.';
					$http_status   = isset( $stream_result['http_status'] ) ? intval( $stream_result['http_status'] ) : 0;

					// Try to extract HTTP status from error type.
					if ( isset( $stream_result['error_type'] ) && 'http_error' === $stream_result['error_type'] ) {
						if ( preg_match( '/HTTP\s+(\d{3})/', $error_message, $m ) ) {
							$http_status = intval( $m[1] );
						}
					}

					$recovery_action = $this->handle_stream_error(
						$error_message,
						$http_status,
						$agent_context,
						$stream_error_attempts,
						$compaction_attempts,
						$run_progress_callback,
						$mode,
						$options,
						$stream_result
					);

					if ( 'retry' === $recovery_action ) {
						$stream_error_attempts++;
						// Continue the inner loop — the retry delay was already applied.
						continue;
					}

					if ( 'compact' === $recovery_action ) {
						$stream_error_attempts = 0;
						$compaction_attempts++;
						// Continue the inner loop — context was compacted.
						continue;
					}

					if ( 'checkpoint' === $recovery_action ) {
						$checkpoint_result = $this->build_soft_deadline_checkpoint_result(
							$agent_context,
							$context,
							$turn_id,
							$model,
							$iteration_count,
							$total_usage,
							'Agent checkpointed after stream timeout near the soft deadline.',
							$run_progress_callback
						);
						return $checkpoint_result;
					}

					// 'fail' — unrecoverable error.
					$stream_error_type = $this->error_recovery->classify_error( $error_message, $http_status );
					$stream_attempts   = max( 1, intval( $stream_error_attempts ) + 1 );
					$error_reply       = '';
					$this->emit(
						Niwa_AI_Agent_Event_Emitter::EVENT_AGENT_END,
						array(
							'turn_id'    => $turn_id,
							'reason'     => 'error',
							'error'      => $error_message,
							'error_type' => $stream_error_type,
							'attempts'   => $stream_attempts,
							'iterations' => $iteration_count,
						),
						$run_progress_callback
					);

					return array(
						'success'    => false,
						'error'      => $error_message,
						'reply'      => $error_reply,
						'stream_error_type' => $stream_error_type,
						'stream_error_attempts' => $stream_attempts,
						'stream_error_retry_limit' => self::MAX_STREAM_RETRIES,
						'turn_id'    => $turn_id,
						'model'      => $model,
						'iterations' => $iteration_count,
						'usage'      => $total_usage,
						'context_snapshot' => is_object( $agent_context ) && method_exists( $agent_context, 'serialize_for_continuation' )
							? $agent_context->serialize_for_continuation()
							: array(),
					);
				}

				// Accumulate usage
				if ( $this->is_cancelled( $run_cancel_probe ) ) {
					$cancelled_reason = ( $soft_deadline > 0 && time() >= $soft_deadline ) ? 'soft_deadline' : 'external';
					$cancel_result = array(
						'success'          => false,
						'cancelled'        => true,
						'cancelled_reason' => $cancelled_reason,
						'error'            => 'Agent cancelled after model response.',
						'reply'            => '',
						'turn_id'          => $turn_id,
						'model'            => $model,
						'iterations'       => $iteration_count,
						'usage'            => $total_usage,
					);
					if ( 'soft_deadline' === $cancelled_reason ) {
						$cancel_result['context_snapshot'] = $agent_context->serialize_for_continuation();
					}
					return $cancel_result;
				}

				if ( ! empty( $stream_result['usage'] ) && is_array( $stream_result['usage'] ) ) {
					Niwa_AI_Agent_Token_Usage::accumulate( $total_usage, $stream_result['usage'] );
				}
				$stream_error_attempts = 0;

				// Append assistant message to context
				$assistant_message_appended = false;
				if ( ! empty( $stream_result['assistant_message'] ) && is_array( $stream_result['assistant_message'] ) ) {
					$agent_context->append_message( $stream_result['assistant_message'] );
					$assistant_message_appended = true;
				}

				// Check for tool calls
				$tool_calls = isset( $stream_result['tool_calls'] ) && is_array( $stream_result['tool_calls'] )
					? $stream_result['tool_calls']
					: array();

				if ( empty( $tool_calls ) ) {
					$pseudo_tool_calls = Niwa_AI_Agent_Pseudo_Tool_Parser::extract_from_content(
						isset( $stream_result['content'] ) ? (string) $stream_result['content'] : '',
						$agent_context
					);
					if ( ! empty( $pseudo_tool_calls ) ) {
						$tool_calls = $pseudo_tool_calls;
						$assistant_tool_message = Niwa_AI_Agent_Pseudo_Tool_Parser::build_assistant_message( $tool_calls );
						if ( $assistant_message_appended && method_exists( $agent_context, 'replace_last_message' ) ) {
							$agent_context->replace_last_message( $assistant_tool_message );
						} else {
							$agent_context->append_message( $assistant_tool_message );
						}
					}
				}

				if ( empty( $tool_calls ) ) {
					if ( $pseudo_tool_call_retries < 1 && Niwa_AI_Agent_Pseudo_Tool_Parser::should_retry_after_text( $stream_result, $agent_context ) ) {
						$pseudo_tool_call_retries++;
						$agent_context->append_message(
							array(
								'role'    => 'user',
								'content' => '[RUNTIME CORRECTION] Your previous assistant message contained a JSON/XML pseudo tool call as plain text. Do not describe or print tool calls. If execution is required, emit a real function/tool call using the provided tools. If no execution is required, answer in normal prose without raw JSON, XML, or command payloads.',
							)
						);
						$has_more_tool_calls = true;
						continue;
					}

					// No tool calls — assistant gave a final text response for this turn.
					$has_more_tool_calls = false;
				} else {
					// Execute tool calls
					$tool_result_messages = $this->execute_tool_calls(
						$bridge,
						$tool_calls,
						$tool_call_counts,
						$prompt,
						$context,
						$mode,
						$options,
						$agent_context,
						$run_cancel_probe,
						$run_progress_callback
					);

					$tool_outcome         = Niwa_AI_Agent_Tool_Execution_Outcome::normalize( $tool_result_messages );
					$tool_cancelled       = $tool_outcome['cancelled'];
					$tool_terminal        = $tool_outcome['terminal'];
					$terminal_reply       = $tool_outcome['reply'];
					$terminal_context     = $tool_outcome['terminal_context'];
					$partial_tool_results = $tool_outcome['partial_results'];

					if ( $tool_terminal ) {
						foreach ( $partial_tool_results as $partial_msg ) {
							$agent_context->append_message( $partial_msg );
						}

						if ( Niwa_AI_Agent_Workspace_Apply_Finalizer::should_finalize_with_llm( $terminal_context, $context ) ) {
							$llm_terminal_reply = $this->build_main_coding_workspace_apply_llm_terminal_reply(
								$agent_context,
								$terminal_context,
								$context,
								$run_progress_callback
							);
							if ( '' !== $llm_terminal_reply ) {
								$terminal_reply = $llm_terminal_reply;
							}
						}

						Niwa_AI_Agent_Terminal_Audit::append_event(
							$turn_id,
							$iteration_count,
							$terminal_context,
							$tool_call_counts,
							$terminal_reply
						);

						$this->emit(
							Niwa_AI_Agent_Event_Emitter::EVENT_AGENT_END,
							array(
								'turn_id'    => $turn_id,
								'reason'     => isset( $terminal_context['reason'] ) ? (string) $terminal_context['reason'] : 'terminal_loop_guard',
								'iterations' => $iteration_count,
							),
							$run_progress_callback
						);
						return array(
							'success'    => true,
							'reply'      => $terminal_reply,
							'model'      => $model,
							'turn_id'    => $turn_id,
							'iterations' => $iteration_count,
							'usage'      => $total_usage,
							'stopped_by_loop_guard' => true,
							'context_snapshot' => $agent_context->serialize_for_continuation(),
						);
					}

					if ( $tool_cancelled ) {
						// Append any partial tool results to context BEFORE serializing for continuation.
						foreach ( $partial_tool_results as $partial_msg ) {
							$agent_context->append_message( $partial_msg );
						}

						// Cancelled during tool execution.
						$cancelled_reason = ( $soft_deadline > 0 && time() >= $soft_deadline ) ? 'soft_deadline' : 'external';
						$this->emit(
							Niwa_AI_Agent_Event_Emitter::EVENT_AGENT_END,
							array(
								'turn_id'          => $turn_id,
								'reason'            => 'cancelled',
								'cancelled_reason' => $cancelled_reason,
								'iterations'       => $iteration_count,
							),
							$run_progress_callback
						);

						$cancel_result = array(
							'success'          => false,
							'cancelled'        => true,
							'cancelled_reason' => $cancelled_reason,
							'error'            => 'Agent cancelled during tool execution.',
							'reply'            => '',
							'turn_id'          => $turn_id,
							'model'            => $model,
							'iterations'       => $iteration_count,
							'usage'            => $total_usage,
						);

						if ( 'soft_deadline' === $cancelled_reason ) {
							$cancel_result['context_snapshot'] = $agent_context->serialize_for_continuation();
						}

						return $cancel_result;
					}

					// Append tool result messages to context.
					foreach ( $tool_result_messages as $tool_msg ) {
						$agent_context->append_message( $tool_msg );
					}
					// Tools were called — loop continues.
					$has_more_tool_calls = true;
				}

				// Emit turn_end
				$this->emit(
					Niwa_AI_Agent_Event_Emitter::EVENT_TURN_END,
					array(
						'turn_id'    => $turn_id,
						'iteration'  => $iteration_count,
						'tool_count' => count( $tool_calls ),
					),
					$run_progress_callback
				);

				// Check for new steering messages
				$new_steering = $this->get_steering_messages();
				if ( ! empty( $new_steering ) ) {
					$has_pending_messages = true;
				}

			} // End inner loop.

			// Check for follow-up messages
			$follow_ups = $this->get_follow_up_messages();
			if ( empty( $follow_ups ) ) {
				// No follow-ups — agent is done.
				break;
			}

			// Inject follow-up messages and continue the outer loop.
			$this->inject_messages( $follow_ups, $agent_context );

		} // End outer loop.

		/* Post-loop: build result */

		$empty_response_diagnostic = false;

		// Extract the final answer from the last assistant message.
		if ( '' === $final_answer ) {
			$final_answer = Niwa_AI_Agent_Message_Utils::extract_last_assistant_content( $agent_context );
		}

		if ( '' === $final_answer ) {
			// Diagnostic logging — helps debug silent empty-response failures.
			$diag = Niwa_AI_Agent_Empty_Response_Diagnostic::build( $agent_context, $iteration_count, $model, $total_usage );
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			error_log( '[Niwa AI] Agent returned empty response. ' . $diag['log_line'] );

			$final_answer = $diag['user_message'];
			$empty_response_diagnostic = true;
		}

		$empty_response_is_failure = $empty_response_diagnostic && 'subagent' === $mode;

		// Emit agent_end
		$this->emit(
			Niwa_AI_Agent_Event_Emitter::EVENT_AGENT_END,
			array(
				'turn_id'    => $turn_id,
				'reason'     => 'completed',
				'iterations' => $iteration_count,
				'usage'      => $total_usage,
			),
			$run_progress_callback
		);

		return array(
			'success'     => ! $empty_response_is_failure,
			'reply'       => $final_answer,
			'error'       => $empty_response_is_failure ? $final_answer : '',
			'model'       => $model,
			'turn_id'     => $turn_id,
			'iterations'  => $iteration_count,
			'usage'       => $total_usage,
			'empty_response_diagnostic' => $empty_response_diagnostic,
			'context_snapshot' => is_object( $agent_context ) && method_exists( $agent_context, 'serialize_for_continuation' )
				? $agent_context->serialize_for_continuation()
				: array(),
		);
	}

	/* Streaming */

	/**
	 * Stream an assistant response from the LLM.
	 *
	 * Validates and sanitizes messages, checks context window utilization,
	 * attempts SSE streaming via Niwa_AI_Agent_Stream_Reader, and falls
	 * back to the blocking chat_complete approach on streaming failure.
	 *
	 * @param Niwa_AI_Agent_Context $agent_context The current agent context.
	 * @param callable|null         $run_progress_callback Progress callback for this run.
	 *
	 * @return array Stream reader result format: {success, role, content,
	 *               tool_calls, stop_reason, usage, assistant_message, error, error_type}.
	 */
	private function stream_assistant_response( $agent_context, $run_progress_callback = null ) {
		// Validate messages
		$messages = $agent_context->get_messages();
		$messages = $this->message_validator->validate_and_sanitize( $messages );

		// Apply bootstrap budget
		$system_prompt = $agent_context->get_system_prompt();
		if ( mb_strlen( $system_prompt ) > self::BOOTSTRAP_MAX_CHARS ) {
			$agent_context->set_system_prompt(
				mb_substr( $system_prompt, 0, self::BOOTSTRAP_MAX_CHARS - 30 ) . "\n\n[System prompt truncated]"
			);
		}

		$agent_context->set_messages( $messages );

		// Check context window
		$eval = Niwa_AI_Context_Window_Guard::evaluate_context( $agent_context );

		if ( $eval['should_compact'] ) {
			$this->emit(
				Niwa_AI_Agent_Event_Emitter::EVENT_COMPACTION_START,
				array(
					'status'       => $eval['status'],
					'usage_ratio'  => $eval['usage_ratio'],
					'total_tokens' => $eval['total_tokens'],
					'budget'       => $eval['budget'],
				),
				$run_progress_callback
			);

			$compact_result = Niwa_AI_Context_Window_Guard::compact_with_retry(
				$agent_context->get_messages(),
				$agent_context->get_system_prompt(),
				$agent_context->get_model(),
				$this->ai_client,
				$agent_context->get_auth_mode(),
				$agent_context->get_creds(),
				$agent_context->get_tools(),
				$agent_context->get_turn_frame()
			);

			if ( ! empty( $compact_result['messages'] ) ) {
				$agent_context->set_messages( $compact_result['messages'] );
			}

			$this->emit(
				Niwa_AI_Agent_Event_Emitter::EVENT_COMPACTION_END,
				array(
					'success'  => ! empty( $compact_result['success'] ),
					'attempts' => isset( $compact_result['attempts'] ) ? $compact_result['attempts'] : 0,
				),
				$run_progress_callback
			);
		}

		// Build the API payload
		$api_payload = $agent_context->get_context_for_api();

		// Emit message_start
		$this->emit(
			Niwa_AI_Agent_Event_Emitter::EVENT_MESSAGE_START,
			array(
				'model' => $agent_context->get_model(),
			),
			$run_progress_callback
		);

		$agent_context->set_streaming( true );

		// Attempt SSE streaming via Agent Stream Reader
		// The stream reader now supports both Chat Completions and Responses API
		// SSE formats. OAuth/Codex models are handled natively by the stream reader
		// using the Responses API protocol with appropriate endpoints and headers.
		$stream_result = $this->attempt_sse_stream( $agent_context, $api_payload, $run_progress_callback );

		if ( null !== $stream_result ) {
			$agent_context->set_streaming( false );
			$this->capture_response_id_for_continuation( $agent_context, $stream_result );

			// Emit message_end
			if ( ! empty( $stream_result['success'] ) ) {
				$this->emit(
					Niwa_AI_Agent_Event_Emitter::EVENT_MESSAGE_END,
					array(
						'content'     => isset( $stream_result['content'] ) ? $stream_result['content'] : '',
						'tool_calls'  => isset( $stream_result['tool_calls'] ) ? $stream_result['tool_calls'] : array(),
						'stop_reason' => isset( $stream_result['stop_reason'] ) ? $stream_result['stop_reason'] : '',
					),
					$run_progress_callback
				);
			}

			return $stream_result;
		}

		// Fallback: blocking chat_complete via AI client
		$fallback_result = $this->fallback_chat_complete( $agent_context, $api_payload );

		$agent_context->set_streaming( false );
		$this->capture_response_id_for_continuation( $agent_context, $fallback_result );

		if ( ! empty( $fallback_result['success'] ) ) {
			$this->emit(
				Niwa_AI_Agent_Event_Emitter::EVENT_MESSAGE_END,
				array(
					'content'     => isset( $fallback_result['content'] ) ? $fallback_result['content'] : '',
					'tool_calls'  => isset( $fallback_result['tool_calls'] ) ? $fallback_result['tool_calls'] : array(),
					'stop_reason' => isset( $fallback_result['stop_reason'] ) ? $fallback_result['stop_reason'] : '',
				),
				$run_progress_callback
			);
		}

		return $fallback_result;
	}

	private function capture_response_id_for_continuation( $agent_context, $result ) {
		if ( ! is_object( $agent_context ) || ! method_exists( $agent_context, 'set_previous_response_id' ) || ! is_array( $result ) ) {
			return;
		}

		$response_id = isset( $result['response_id'] ) ? trim( (string) $result['response_id'] ) : '';
		if ( '' !== $response_id ) {
			$agent_context->set_previous_response_id( $response_id );
		}
	}

	/**
	 * Attempt real SSE streaming via Niwa_AI_Agent_Stream_Reader.
	 *
	 * @param Niwa_AI_Agent_Context $agent_context Agent context.
	 * @param array                 $api_payload   Payload from get_context_for_api().
	 * @param callable|null         $run_progress_callback Progress callback.
	 *
	 * @return array|null Stream result, or null if streaming is unavailable
	 *                    and the caller should fall back.
	 */
	private function attempt_sse_stream( $agent_context, $api_payload, $run_progress_callback = null ) {
		// Guard: if the Stream Reader class is not available, return null for fallback.
		if ( ! class_exists( 'Niwa_AI_Agent_Stream_Reader' ) ) {
			return null;
		}

		$api_messages = isset( $api_payload['messages'] ) ? $api_payload['messages'] : array();
		$api_tools    = isset( $api_payload['tools'] ) ? $api_payload['tools'] : null;
		$timeout      = isset( $api_payload['request_timeout'] ) ? max( 10, intval( $api_payload['request_timeout'] ) ) : self::DEFAULT_TIMEOUT;

		$request_payload_json  = wp_json_encode( $api_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
		$request_payload_bytes = is_string( $request_payload_json ) ? strlen( $request_payload_json ) : 0;

		$this->emit_progress_callback(
			$run_progress_callback,
			'model_request',
			array(
				'stage'                 => 'request_started',
				'timeout_seconds'       => $timeout,
				'request_payload_bytes' => $request_payload_bytes,
				'message_count'         => is_array( $api_messages ) ? count( $api_messages ) : 0,
				'tool_count'            => is_array( $api_tools ) ? count( $api_tools ) : 0,
			)
		);

		$stream_reader = new Niwa_AI_Agent_Stream_Reader(
			$agent_context->get_model(),
			$api_messages,
			$api_tools,
			$agent_context->get_auth_mode(),
			$agent_context->get_creds(),
			array(
				'timeout'              => $timeout,
				'tool_choice'          => isset( $api_payload['tool_choice'] ) ? $api_payload['tool_choice'] : 'auto',
			)
		);

		// Build an event callback that forwards stream events.
		$emitter = $this->event_emitter;
		$progress_cb = $run_progress_callback;
		$self = $this;

		$stream_event_handler = function ( $event, $data ) use ( $emitter, $progress_cb, $self ) {
			if ( 'message_update' === $event ) {
				$emitter->emit(
					Niwa_AI_Agent_Event_Emitter::EVENT_MESSAGE_UPDATE,
					array(
						'delta'   => isset( $data['delta'] ) ? $data['delta'] : '',
						'content' => isset( $data['content'] ) ? $data['content'] : '',
					)
				);

				// Also forward to legacy progress callback.
				$self->emit_progress_callback( $progress_cb, 'message_delta', $data );
				return;
			}

			if ( 'transport_heartbeat' === $event ) {
				$payload = is_array( $data ) ? $data : array();
				$payload['stage'] = isset( $payload['stage'] )
					? 'transport_' . sanitize_key( (string) $payload['stage'] )
					: 'transport_heartbeat';
				$self->emit_progress_callback( $progress_cb, 'model_request', $payload );
				return;
			}

			if ( 'tool_call_update' === $event ) {
				$payload          = is_array( $data ) ? $data : array();
				$payload['stage'] = 'tool_call_event';
				$self->emit_progress_callback( $progress_cb, 'model_request', $payload );
				return;
			}

			if ( 'done' === $event ) {
				$payload          = is_array( $data ) ? $data : array();
				$payload['stage'] = 'stream_done';
				$self->emit_progress_callback( $progress_cb, 'model_request', $payload );
			}
		};

		$started_at = microtime( true );
		$result     = $stream_reader->stream( $stream_event_handler );

		if ( is_array( $result ) && ! isset( $result['elapsed_ms'] ) ) {
			$result['elapsed_ms'] = (int) round( ( microtime( true ) - $started_at ) * 1000 );
		}

		$this->emit_progress_callback(
			$run_progress_callback,
			'model_request',
			array(
				'stage'                => 'request_finished',
				'success'              => ! empty( $result['success'] ),
				'elapsed_ms'           => isset( $result['elapsed_ms'] ) ? intval( $result['elapsed_ms'] ) : 0,
				'timeout_seconds'      => isset( $result['timeout_seconds'] ) ? intval( $result['timeout_seconds'] ) : $timeout,
				'http_status'          => isset( $result['http_status'] ) ? intval( $result['http_status'] ) : 0,
				'transport'            => isset( $result['transport'] ) ? (string) $result['transport'] : '',
				'stop_reason'          => isset( $result['stop_reason'] ) ? (string) $result['stop_reason'] : '',
				'tool_call_count'      => isset( $result['tool_calls'] ) && is_array( $result['tool_calls'] ) ? count( $result['tool_calls'] ) : 0,
				'events_seen'          => isset( $result['events_seen'] ) ? intval( $result['events_seen'] ) : 0,
				'bytes_read'           => isset( $result['bytes_read'] ) ? intval( $result['bytes_read'] ) : 0,
				'model_request_timing' => isset( $result['model_request_timing'] ) && is_array( $result['model_request_timing'] ) ? $result['model_request_timing'] : array(),
			)
		);

		return $result;
	}

	/**
	 * Fallback: use the blocking AI client chat_complete method.
	 *
	 * Used when SSE streaming is unavailable or fails entirely.
	 *
	 * @param Niwa_AI_Agent_Context $agent_context Agent context.
	 * @param array                 $api_payload   API payload from get_context_for_api().
	 *
	 * @return array Result in the same format as stream() return value.
	 */
	private function fallback_chat_complete( $agent_context, $api_payload ) {
		$api_messages = isset( $api_payload['messages'] ) ? $api_payload['messages'] : array();
		$api_tools    = ! empty( $api_payload['tools'] ) ? $api_payload['tools'] : null;
		$timeout      = isset( $api_payload['request_timeout'] ) ? max( 10, intval( $api_payload['request_timeout'] ) ) : self::DEFAULT_TIMEOUT;

		$result = $this->ai_client->chat_complete(
			$agent_context->get_model(),
			$api_messages,
			$agent_context->get_auth_mode(),
			$agent_context->get_creds(),
			$timeout,
			$api_tools,
			isset( $api_payload['tool_choice'] ) ? $api_payload['tool_choice'] : ( null !== $api_tools ? 'auto' : null )
		);

		if ( empty( $result['success'] ) ) {
			return array(
				'success'           => false,
				'role'              => 'assistant',
				'content'           => '',
				'tool_calls'        => array(),
				'stop_reason'       => '',
				'usage'             => array(),
				'assistant_message' => array(),
				'error'             => isset( $result['error'] ) ? (string) $result['error'] : 'Fallback chat_complete failed.',
				'error_type'        => 'http_error',
			);
		}

		// Normalize the AI client response into the stream reader format.
		$content     = isset( $result['answer'] ) ? (string) $result['answer'] : '';
		$tool_calls  = isset( $result['tool_calls'] ) && is_array( $result['tool_calls'] ) ? $result['tool_calls'] : array();
		$message     = isset( $result['message'] ) && is_array( $result['message'] ) ? $result['message'] : array();
		$stop_reason = ! empty( $tool_calls ) ? 'tool_calls' : 'stop';

		// Normalize tool call format to match stream reader output.
		$normalized_tool_calls = array();
		foreach ( $tool_calls as $tc ) {
			$tc_id   = isset( $tc['id'] ) ? (string) $tc['id'] : '';
			$tc_name = isset( $tc['name'] ) ? (string) $tc['name'] : '';

			// Handle both flat format ({name, arguments}) and nested format ({function: {name, arguments}}).
			if ( empty( $tc_name ) && isset( $tc['function']['name'] ) ) {
				$tc_name = (string) $tc['function']['name'];
			}

			$tc_args = '';
			if ( isset( $tc['arguments'] ) ) {
				$tc_args = is_string( $tc['arguments'] ) ? $tc['arguments'] : wp_json_encode( $tc['arguments'] );
			} elseif ( isset( $tc['function']['arguments'] ) ) {
				$tc_args = is_string( $tc['function']['arguments'] ) ? $tc['function']['arguments'] : wp_json_encode( $tc['function']['arguments'] );
			}

			$normalized_tool_calls[] = array(
				'id'        => $tc_id,
				'name'      => $tc_name,
				'arguments' => $tc_args,
			);
		}

		// Build assistant message if not provided by the AI client.
		if ( empty( $message ) ) {
			$message = array(
				'role'    => 'assistant',
				'content' => $content,
			);

			if ( ! empty( $normalized_tool_calls ) ) {
				$api_tool_calls = array();
				foreach ( $normalized_tool_calls as $ntc ) {
					$api_tool_calls[] = array(
						'id'       => $ntc['id'],
						'type'     => 'function',
						'function' => array(
							'name'      => $ntc['name'],
							'arguments' => $ntc['arguments'],
						),
					);
				}
				$message['tool_calls'] = $api_tool_calls;
			}
		}

		return array(
			'success'           => true,
			'role'              => 'assistant',
			'content'           => $content,
			'tool_calls'        => $normalized_tool_calls,
			'stop_reason'       => $stop_reason,
			'usage'             => isset( $result['usage'] ) && is_array( $result['usage'] ) ? $result['usage'] : array(),
			'assistant_message' => $message,
			'response_id'       => isset( $result['response_id'] ) ? (string) $result['response_id'] : '',
			'error'             => '',
			'error_type'        => '',
		);
	}

	/* Tool execution */

	/**
	 * Execute a batch of tool calls via the bridge.
	 *
	 * Includes loop-guard logic to prevent infinite tool call loops,
	 * before/after hooks, event emission, and tool result truncation.
	 *
	 * @param object                $bridge                 The turn bridge.
	 * @param array                 $tool_calls             Tool calls from the assistant response.
	 * @param array                 &$tool_call_counts      Mutable counts for loop detection.
	 * @param string                $prompt                 Original user prompt.
	 * @param array                 $context                Turn context.
	 * @param string                $mode                   Turn mode ('main' or 'subagent').
	 * @param array                 $options                Turn options.
	 * @param Niwa_AI_Agent_Context $agent_context          Agent context.
	 * @param callable|null         $run_cancel_probe       Cancel probe for this run.
	 * @param callable|null         $run_progress_callback  Progress callback for this run.
	 *
	 * @return array|null Array of tool result messages, or null if cancelled.
	 */
	private function execute_tool_calls( $bridge, $tool_calls, &$tool_call_counts, $prompt, $context, $mode, $options, $agent_context, $run_cancel_probe, $run_progress_callback ) {
		$result_messages = array();
		$soft_deadline   = isset( $options['soft_deadline'] ) ? intval( $options['soft_deadline'] ) : 0;

		foreach ( $tool_calls as $call ) {
			// Check cancellation before each tool call.
			if ( $this->is_cancelled( $run_cancel_probe ) ) {
				// Return partial results so already-executed tool outputs are preserved in context.
				return array( '_cancelled' => true, '_partial_results' => $result_messages );
			}

			$call_id   = isset( $call['id'] ) ? (string) $call['id'] : '';
			$call_name = isset( $call['name'] ) ? (string) $call['name'] : '';
			$call_args = isset( $call['arguments'] ) ? $call['arguments'] : '';
			$decoded_args = Niwa_AI_Agent_Tool_Call_Utils::decode_args( $call_args );

			if ( 'main' === (string) $mode && ! empty( $tool_call_counts['_capability_gap_pending'] ) && $this->is_capability_gap_blocked_followup_tool( $call_name, $decoded_args ) ) {
				$result_messages[] = Niwa_AI_Agent_Tool_Call_Utils::result_message(
					$call_id,
					array(
						'ok'    => false,
						'error' => 'Capability gap is awaiting founder approval. Stop inspecting code/files in this run and ask the founder whether to create the missing capability.',
						'required_next_step' => 'ask_founder_for_extension_approval',
					)
				);
				continue;
			}

			// Loop guard
			$loop_guard_result = $this->check_loop_guard( $call_name, $decoded_args, $tool_call_counts, $mode, $options );

			if ( null !== $loop_guard_result ) {
				$loop_guard_result = $this->add_main_coding_read_budget_to_array_payload( $loop_guard_result, $call_name, $decoded_args, $tool_call_counts, $mode );
				$result_messages[] = Niwa_AI_Agent_Tool_Call_Utils::result_message( $call_id, $loop_guard_result );
				if ( ! empty( $loop_guard_result['agent_loop_terminal'] ) ) {
					return Niwa_AI_Agent_Tool_Execution_Outcome::terminal_result(
						isset( $loop_guard_result['reply'] ) ? (string) $loop_guard_result['reply'] : '',
						$result_messages,
						array(
							'last_tool' => $call_name,
							'guard'     => isset( $loop_guard_result['guard'] ) ? (string) $loop_guard_result['guard'] : 'loop_guard',
							'result'    => $loop_guard_result,
						)
					);
				}
				continue;
			}

			// Before hook.
			$this->invoke_before_tool_call( $call, $agent_context );

			// Mark as pending.
			$agent_context->add_pending_tool_call( $call_id );

			// Emit tool_execution_start.
			$this->emit_tool_execution_event(
				Niwa_AI_Agent_Event_Emitter::EVENT_TOOL_EXECUTION_START,
				$call_id,
				$call_name,
				$run_progress_callback,
				array(
					'arguments' => $decoded_args,
				)
			);

			// Execute via bridge
			// Reset PHP time limit before each tool call. The limit must be
			// longer than the tool's own timeout; otherwise PHP can kill the
			// whole actor request before the tool records its terminal event.
			$this->apply_tool_execution_time_limit( $call_name, $decoded_args, $soft_deadline );

			$bridge_call = Niwa_AI_Agent_Tool_Call_Utils::bridge_call( $call_id, $call_name, $call_args );

			$payloads = $bridge->execute_turn_tool_calls(
				array( $bridge_call ),
				$prompt,
				$context,
				$mode,
				$options
			);

			// Restore full worker time limit after successful tool execution.
			$this->restore_worker_time_limit_after_tool( $soft_deadline );

			// Normalize the bridge result.
			$tool_payload = Niwa_AI_Agent_Tool_Payload_Normalizer::normalize( $payloads, $call_id );
			$this->record_main_coding_tool_result_for_guard( $call_name, $tool_payload, $tool_call_counts );
			$raw_tool_payload = $tool_payload;

			// Truncate and compact oversized tool results for the next model turn.
			$tool_payload = $this->prepare_tool_payload_for_model( $tool_payload, $call_name, $decoded_args, $tool_call_counts, $mode, $agent_context );
			if ( $this->tool_payload_contains_capability_gap( $tool_payload ) ) {
				$tool_call_counts['_capability_gap_pending'] = true;
			}

			// Remove from pending.
			$agent_context->remove_pending_tool_call( $call_id );

			// Emit tool_execution_end.
			$this->emit_tool_execution_event(
				Niwa_AI_Agent_Event_Emitter::EVENT_TOOL_EXECUTION_END,
				$call_id,
				$call_name,
				$run_progress_callback
			);

			// After hook.
			$this->invoke_after_tool_call( $call, $raw_tool_payload, $agent_context );

			$result_messages[] = $tool_payload;

			$terminal_result = $this->terminal_result_from_tool_payload( $call_name, $tool_payload, $result_messages );
			if ( null !== $terminal_result ) {
				return $terminal_result;
			}

			if ( 'main' === (string) $mode && 'main_coding_workspace_apply' === $call_name && $this->main_coding_workspace_apply_succeeded( $tool_payload ) ) {
				$terminal_reply = Niwa_AI_Agent_Workspace_Apply_Finalizer::terminal_reply( $this->decode_tool_payload_content( $tool_payload ), $context );
				return Niwa_AI_Agent_Tool_Execution_Outcome::terminal_result(
					$terminal_reply,
					$result_messages,
					Niwa_AI_Agent_Tool_Execution_Outcome::workspace_apply_context( $call_name, $this->decode_tool_payload_content( $tool_payload ) )
				);
			}

			// Deadline check after tool execution — stop batch early if past deadline.
			if ( $soft_deadline > 0 && time() >= $soft_deadline ) {
				return array( '_cancelled' => true, '_partial_results' => $result_messages );
			}
		}

		return $result_messages;
	}

	private function terminal_result_from_tool_payload( $call_name, $tool_payload, $result_messages ) {
		$decoded = $this->decode_tool_payload_content( $tool_payload );
		if ( empty( $decoded ) || ! is_array( $decoded ) || empty( $decoded['success'] ) ) {
			return null;
		}

		if ( empty( $decoded['terminal_after_tool'] ) && empty( $decoded['agent_loop_terminal'] ) ) {
			return null;
		}

		$reply = '';
		foreach ( array( 'final_reply', 'reply', 'summary' ) as $key ) {
			if ( ! empty( $decoded[ $key ] ) && is_string( $decoded[ $key ] ) ) {
				$reply = trim( (string) $decoded[ $key ] );
				break;
			}
		}

		return Niwa_AI_Agent_Tool_Execution_Outcome::terminal_result(
			$reply,
			$result_messages,
			array(
				'last_tool' => (string) $call_name,
				'reason'    => ! empty( $decoded['terminal_reason'] ) ? (string) $decoded['terminal_reason'] : 'tool_declared_terminal',
				'result'    => $decoded,
			)
		);
	}

	private function invoke_before_tool_call( $call, $agent_context ) {
		if ( is_callable( $this->before_tool_call ) ) {
			call_user_func( $this->before_tool_call, $call, $agent_context );
		}
	}

	private function invoke_after_tool_call( $call, $tool_payload, $agent_context ) {
		if ( is_callable( $this->after_tool_call ) ) {
			call_user_func( $this->after_tool_call, $call, $tool_payload, $agent_context );
		}
	}

	private function prepare_tool_payload_for_model( $tool_payload, $call_name, $decoded_args, $tool_call_counts, $mode, $agent_context ) {
		if ( ! isset( $tool_payload['content'] ) || ! is_string( $tool_payload['content'] ) ) {
			return $tool_payload;
		}

		$tool_payload = Niwa_AI_Agent_Tool_Payload_Normalizer::compact_for_model(
			$tool_payload,
			$call_name,
			self::TOOL_OUTPUT_COMPACT_THRESHOLD,
			self::TOOL_OUTPUT_COMPACT_STRING_LIMIT
		);

		$tool_payload['content'] = Niwa_AI_Context_Window_Guard::truncate_tool_result(
			$tool_payload['content'],
			$agent_context->get_model()
		);

		return $this->add_main_coding_read_budget_to_tool_payload( $tool_payload, $call_name, $decoded_args, $tool_call_counts, $mode );
	}

	private function emit_tool_execution_event( $event, $call_id, $call_name, $run_progress_callback = null, $extra = array() ) {
		$payload = array_merge(
			array(
				'tool_call_id' => $call_id,
				'name'         => $call_name,
			),
			is_array( $extra ) ? $extra : array()
		);

		$this->emit( $event, $payload, $run_progress_callback );
	}

	private function apply_tool_execution_time_limit( $call_name, $decoded_args, $soft_deadline ) {
		if ( function_exists( 'set_time_limit' ) ) {
			@set_time_limit( $this->resolve_tool_execution_time_limit( $call_name, $decoded_args, $soft_deadline ) );
		}
	}

	private function restore_worker_time_limit_after_tool( $soft_deadline ) {
		if ( function_exists( 'set_time_limit' ) ) {
			@set_time_limit( $this->resolve_worker_time_limit_after_tool( $soft_deadline ) );
		}
	}

	private function resolve_worker_time_limit_after_tool( $soft_deadline ) {
		return $soft_deadline > 0 ? max( 30, $soft_deadline - time() + self::DEFAULT_TIMEOUT ) : self::DEFAULT_TIMEOUT * 3;
	}

	private function main_coding_workspace_apply_succeeded( $tool_payload ) {
		$decoded = $this->decode_tool_payload_content( $tool_payload );
		if ( empty( $decoded ) || ! is_array( $decoded ) || empty( $decoded['success'] ) ) {
			return false;
		}

		if ( ! empty( $decoded['rollback_performed'] ) || ! empty( $decoded['rollback'] ) ) {
			return false;
		}

		$watchdog = isset( $decoded['watchdog'] ) && is_array( $decoded['watchdog'] ) ? $decoded['watchdog'] : array();
		return empty( $watchdog ) || ! empty( $watchdog['success'] ) || ! empty( $watchdog['skipped'] );
	}

	private function add_main_coding_read_budget_to_tool_payload( $tool_payload, $tool_name, $args, $tool_call_counts, $mode = 'main' ) {
		if ( ! is_array( $tool_payload ) || ! isset( $tool_payload['content'] ) || ! is_string( $tool_payload['content'] ) ) {
			return $tool_payload;
		}

		$decoded = json_decode( (string) $tool_payload['content'], true );
		if ( ! is_array( $decoded ) ) {
			return $tool_payload;
		}

		$decoded = $this->add_main_coding_read_budget_to_array_payload( $decoded, $tool_name, $args, $tool_call_counts, $mode );
		$encoded = wp_json_encode( $decoded );
		if ( is_string( $encoded ) && '' !== $encoded ) {
			$tool_payload['content'] = $encoded;
		}

		return $tool_payload;
	}

	private function add_main_coding_read_budget_to_array_payload( $payload, $tool_name, $args, $tool_call_counts, $mode = 'main' ) {
		if ( 'main' !== (string) $mode || ! is_array( $payload ) ) {
			return $payload;
		}
		if ( ! $this->is_main_coding_read_only_tool_call( $tool_name, $args ) && empty( $payload['read_only_count'] ) ) {
			return $payload;
		}

		$payload['_read_budget'] = $this->get_main_coding_read_budget_state( $tool_call_counts );
		return $payload;
	}

	private function get_main_coding_read_budget_state( $tool_call_counts ) {
		$tool_call_counts = is_array( $tool_call_counts ) ? $tool_call_counts : array();
		$used             = isset( $tool_call_counts['main_coding_read_only_tool_count'] ) ? intval( $tool_call_counts['main_coding_read_only_tool_count'] ) : 0;
		$limit            = intval( self::MAIN_CODING_READ_ONLY_TOOL_LIMIT );
		$terminal_at      = max( $limit + 1, (int) ceil( $limit * 1.5 ) );

		return array(
			'used'         => max( 0, $used ),
			'limit'        => $limit,
			'remaining'    => max( 0, $limit - $used ),
			'percent_used' => $limit > 0 ? min( 999, (int) round( ( $used / $limit ) * 100 ) ) : 0,
			'warning_at'   => $limit,
			'terminal_at'  => $terminal_at,
			'scope'        => 'main_coding_read_only_tools',
		);
	}

	private function build_main_coding_workspace_apply_llm_terminal_reply( $agent_context, $terminal_context, $context, $run_progress_callback = null ) {
		if ( ! is_object( $agent_context ) || ! method_exists( $agent_context, 'get_messages' ) ) {
			return '';
		}

		$finalizer_context = new Niwa_AI_Agent_Context(
			array(
				'system_prompt'         => $agent_context->get_system_prompt(),
				'messages'              => $agent_context->get_messages(),
				'tools'                 => array(),
				'model'                 => $agent_context->get_model(),
				'previous_response_id'  => method_exists( $agent_context, 'get_previous_response_id' ) ? $agent_context->get_previous_response_id() : '',
				'auth_mode'             => $agent_context->get_auth_mode(),
				'creds'                 => $agent_context->get_creds(),
				'api_options'           => Niwa_AI_Agent_Workspace_Apply_Finalizer::no_tool_api_options( $agent_context->get_api_options() ),
			)
		);

		$finalizer_context->append_message(
			array(
				'role'                  => 'user',
				'content'               => Niwa_AI_Agent_Workspace_Apply_Finalizer::llm_summary_prompt( $terminal_context, $context ),
				'ephemeral'             => true,
				'niwa_runtime_control'  => true,
			)
		);

		$result = $this->stream_assistant_response( $finalizer_context, $run_progress_callback );

		if ( empty( $result['success'] ) ) {
			return '';
		}

		$tool_calls = isset( $result['tool_calls'] ) && is_array( $result['tool_calls'] ) ? $result['tool_calls'] : array();
		if ( ! empty( $tool_calls ) ) {
			return '';
		}

		$content = isset( $result['content'] ) ? trim( (string) $result['content'] ) : '';
		return $content;
	}

	private function decode_tool_payload_content( $tool_payload ) {
		if ( ! is_array( $tool_payload ) || empty( $tool_payload['content'] ) || ! is_string( $tool_payload['content'] ) ) {
			return array();
		}

		$decoded = json_decode( (string) $tool_payload['content'], true );
		return is_array( $decoded ) ? $decoded : array();
	}

	private function resolve_tool_execution_time_limit( $tool_name, $args, $soft_deadline = 0 ) {
		$tool_name = sanitize_key( (string) $tool_name );
		$args      = is_array( $args ) ? $args : array();
		$limit     = 180;

		if ( 'main_coding_shell_run' === $tool_name ) {
			$tool_timeout = 0;
			foreach ( array( 'timeout', 'timeout_seconds' ) as $key ) {
				if ( isset( $args[ $key ] ) ) {
					$tool_timeout = max( $tool_timeout, intval( $args[ $key ] ) );
				}
			}
			$limit = max( 180, $tool_timeout + 60 );
		} elseif ( in_array( $tool_name, array( 'main_coding_workspace_apply', 'main_coding_test_run', 'main_coding_lint_check', 'main_coding_git_diff', 'main_coding_workspace_prepare', 'main_coding_file_edit' ), true ) ) {
			$limit = 300;
		}

		if ( $soft_deadline > 0 ) {
			$remaining = max( 30, intval( $soft_deadline ) - time() + 90 );
			$limit     = max( $limit, $remaining );
		}

		return max( 60, min( 1200, intval( $limit ) ) );
	}

	/**
	 * Check the loop guard for a tool call.
	 *
	 * Detects:
	 *   - Exact-same call repeated more than EXACT_TOOL_CALL_LIMIT times (identical name + args).
	 *   - Same tool name called more than MAX_SAME_TOOL_CALLS times with any args.
	 *
	 * @param string $name              Tool name.
	 * @param array  $args              Decoded arguments.
	 * @param array  &$tool_call_counts Mutable tracking map.
	 *
	 * @return array|null Error payload if loop detected, null otherwise.
	 */
	private function is_capability_gap_blocked_followup_tool( $name, $args ) {
		$name = (string) $name;
		if ( in_array( $name, array( 'extension_request_create', 'extension_approval_record', 'main_coding_shell_run', 'main_coding_workspace_prepare', 'main_coding_workspace_apply', 'main_coding_workspace_rollback', 'main_coding_file_edit', 'tool_active_list', 'tool_unload' ), true ) ) {
			return false;
		}

		if ( in_array( $name, array( 'code_search', 'file_read', 'file_list', 'batch_read', 'db_schema_get', 'code_intelligence_get', 'wp_environment_capabilities_get' ), true ) ) {
			return true;
		}

		if ( 'tool_load' !== $name || ! is_array( $args ) ) {
			return false;
		}

		$tool_names = isset( $args['tool_names'] ) && is_array( $args['tool_names'] ) ? $args['tool_names'] : array();
		$blocked    = array(
			'code_search',
			'code_scan_run',
			'file_read',
			'file_list',
			'batch_read',
			'db_schema_get',
			'code_intelligence_get',
			'wp_environment_capabilities_get',
		);

		foreach ( $tool_names as $tool_name ) {
			if ( in_array( sanitize_key( (string) $tool_name ), $blocked, true ) ) {
				return true;
			}
		}

		return false;
	}

	private function tool_payload_contains_capability_gap( $payload ) {
		if ( ! is_array( $payload ) || empty( $payload['content'] ) || ! is_string( $payload['content'] ) ) {
			return false;
		}

		$decoded = json_decode( (string) $payload['content'], true );
		return is_array( $decoded ) && ! empty( $decoded['capability_gap'] );
	}

	private function record_main_coding_tool_result_for_guard( $tool_name, $tool_payload, &$tool_call_counts ) {
		if ( 'main_coding_workspace_prepare' !== (string) $tool_name ) {
			return;
		}

		if ( ! is_array( $tool_payload ) || empty( $tool_payload['content'] ) || ! is_string( $tool_payload['content'] ) ) {
			return;
		}

		$decoded = json_decode( (string) $tool_payload['content'], true );
		if ( ! is_array( $decoded ) || empty( $decoded['success'] ) ) {
			return;
		}

		$operation_id = isset( $decoded['operation_id'] ) ? (string) $decoded['operation_id'] : '';
		if ( '' === $operation_id ) {
			return;
		}

		$workspace = isset( $decoded['workspace'] ) && is_array( $decoded['workspace'] ) ? $decoded['workspace'] : array();
		$files     = isset( $decoded['files'] ) && is_array( $decoded['files'] ) ? $decoded['files'] : array();
		$summary   = array(
			'operation_id' => $operation_id,
			'base_dir'     => isset( $workspace['base_dir'] ) ? (string) $workspace['base_dir'] : '',
			'work_dir'     => isset( $workspace['work_dir'] ) ? (string) $workspace['work_dir'] : '',
			'files'        => array(),
		);

		foreach ( $files as $file ) {
			if ( ! is_array( $file ) ) {
				continue;
			}
			$summary['files'][] = array(
				'display_path' => isset( $file['display_path'] ) ? (string) $file['display_path'] : '',
				'work_path'    => isset( $file['work_path'] ) ? (string) $file['work_path'] : '',
				'target_path'  => isset( $file['target_path'] ) ? (string) $file['target_path'] : '',
			);
		}

		if ( empty( $tool_call_counts['main_coding_prepared_workspaces'] ) || ! is_array( $tool_call_counts['main_coding_prepared_workspaces'] ) ) {
			$tool_call_counts['main_coding_prepared_workspaces'] = array();
		}

		$tool_call_counts['main_coding_prepared_workspaces'][ $operation_id ] = $summary;
	}

	private function check_loop_guard( $name, $args, &$tool_call_counts, $mode = 'main', $options = array() ) {
		// Exact-match signature.
		$exact_signature = md5( $name . ':' . wp_json_encode( $args ) );
		$exact_count     = isset( $tool_call_counts[ $exact_signature ] ) ? intval( $tool_call_counts[ $exact_signature ] ) + 1 : 1;
		$tool_call_counts[ $exact_signature ] = $exact_count;

		// Tool-name signature.
		$tool_name_key   = 'tool_name:' . $name;
		$tool_name_count = isset( $tool_call_counts[ $tool_name_key ] ) ? intval( $tool_call_counts[ $tool_name_key ] ) + 1 : 1;
		$tool_call_counts[ $tool_name_key ] = $tool_name_count;

		if ( $exact_count > self::EXACT_TOOL_CALL_LIMIT ) {
			return array(
				'ok'    => false,
				'error' => 'Loop guard: Identical tool call repeated ' . $exact_count . ' times in this agent run (limit is ' . self::EXACT_TOOL_CALL_LIMIT . '). Change strategy and respond based on already collected data.',
			);
		}

		if ( 'tool_search' === $name && $tool_name_count > 8 ) {
			return array(
				'ok'    => false,
				'error' => 'Loop guard: tool_search has been used ' . $tool_name_count . ' times in this run. Use the existing search results to call tool_load, use main_coding_shell_run plus main_coding_workspace_* for one-time code/file work, or call extension_request_create if no native reusable tool exactly fits the task. Do not keep searching with paraphrases.',
			);
		}

		if ( 'tool_unload' === $name && $tool_name_count > 4 ) {
			return array(
				'ok'    => false,
				'error' => 'Loop guard: tool_unload is spinning. Stop unloading and continue the real task.',
			);
		}

		if ( 'main' === (string) $mode ) {
			$main_coding_progress_guard = $this->check_main_coding_write_progress_guard( $name, $args, $tool_call_counts );
			if ( null !== $main_coding_progress_guard ) {
				return $main_coding_progress_guard;
			}

			if (
				in_array( $name, array( 'main_coding_workspace_prepare', 'main_coding_workspace_apply', 'main_coding_workspace_rollback' ), true )
				|| ( 'main_coding_file_edit' === $name && empty( $args['dry_run'] ) )
				|| ( 'main_coding_shell_run' === $name && ! $this->is_main_coding_shell_read_only_call( $args ) )
			) {
				$tool_call_counts['main_coding_shell_read_only_count'] = 0;
				$tool_call_counts['main_coding_read_only_tool_count']  = 0;
				$tool_call_counts['main_coding_read_only_tool_nudged'] = false;
				$tool_call_counts['main_coding_read_only_tool_warned'] = false;
			}

			$main_coding_read_guard = $this->check_main_coding_read_only_tool_guard( $name, $args, $tool_call_counts );
			if ( null !== $main_coding_read_guard ) {
				return $main_coding_read_guard;
			}

			if ( 'main_coding_shell_run' === $name && $this->is_main_coding_shell_read_only_call( $args ) ) {
				$read_key   = 'main_coding_shell_read_only_count';
				$read_count = isset( $tool_call_counts[ $read_key ] ) ? intval( $tool_call_counts[ $read_key ] ) + 1 : 1;
				$tool_call_counts[ $read_key ] = $read_count;
				if ( $read_count > self::MAIN_CODING_READ_ONLY_SHELL_LIMIT ) {
					return array(
						'ok'                  => false,
						'error'               => 'Loop guard: main_coding_shell_run has been used for read-only inspection ' . $read_count . ' times without editing a work copy or applying a workspace. You must now either: 1) call main_coding_workspace_prepare to set up work copies, 2) use a write command (sed -i, echo >, tee) in main_coding_shell_run, or 3) summarize what is blocking the edit and report to the founder.',
						'agent_loop_terminal' => true,
						'stopped_by_loop_guard' => true,
						'guard'               => 'main_coding_read_only_shell_limit',
						'reply'               => 'Zaustavljam ovaj coding turn: previse read-only shell pregleda bez izmene. Sledeci korak mora biti main_coding_workspace_prepare ili shell sa write komandom.',
					);
				}
			}
		}

		$range = $this->extract_tool_call_file_range( $name, $args );
		if ( null !== $range ) {
			$track_key  = 'frange:' . $name . ':' . $range['path'];
			$history    = isset( $tool_call_counts[ $track_key ] ) && is_array( $tool_call_counts[ $track_key ] ) ? $tool_call_counts[ $track_key ] : array();
			$overlapped = 0;
			foreach ( $history as $prev ) {
				if ( ! is_array( $prev ) || count( $prev ) < 2 ) {
					continue;
				}
				if ( max( intval( $prev[0] ), $range['start'] ) <= min( intval( $prev[1] ), $range['end'] ) ) {
					$overlapped++;
				}
			}
			$history[] = array( $range['start'], $range['end'] );
			if ( count( $history ) > 5 ) {
				$history = array_slice( $history, -5 );
			}
			$tool_call_counts[ $track_key ] = $history;

			if ( $overlapped >= 2 ) {
				return array(
					'ok'    => false,
					'error' => sprintf(
						'Loop guard: %s on %s overlaps %d recently read ranges. Read one wider non-overlapping slice, or use code_intelligence_get / code_search instead of scanning blind.',
						$name,
						$range['path'],
						$overlapped
					),
				);
			}
		}

		// Tight per-tool caps for tools that have caused runaway loops (e.g.
		// memory writes that the model keeps queuing up after the user reply
		// is already complete). Hitting this returns an error to the model so
		// it stops calling the tool and produces a final answer.
		$tight_caps = self::TIGHT_TOOL_CAPS;
		if ( isset( $tight_caps[ $name ] ) && $tool_name_count > intval( $tight_caps[ $name ] ) ) {
			return array(
				'ok'                        => false,
				'error'                     => 'Loop guard: Tool "' . $name . '" reached its per-run cap of ' . intval( $tight_caps[ $name ] ) . ' calls. Do not call it again in this run. If the original multi-item task is unfinished, call task_checkpoint with state=continue and exact remaining work; the tool budget renews in the continuation. This internal renewable budget is not an external blocker.',
				'renewable_in_continuation' => true,
				'required_checkpoint_state' => 'continue',
				'continuation_reason'        => 'renewable_tool_budget',
				'limited_tool'               => $name,
				'per_run_cap'                => intval( $tight_caps[ $name ] ),
			);
		}

		if ( $tool_name_count > self::MAX_SAME_TOOL_CALLS ) {
			return array(
				'ok'    => false,
				'error' => 'Loop guard: Tool "' . $name . '" called ' . $tool_name_count . ' times in this agent run (limit is ' . self::MAX_SAME_TOOL_CALLS . '). Stop calling this tool and produce the final user-facing reply now.',
			);
		}

		return null;
	}

	private function check_main_coding_read_only_tool_guard( $name, $args, &$tool_call_counts ) {
		if ( ! $this->is_main_coding_read_only_tool_call( $name, $args ) ) {
			return null;
		}

		$key   = 'main_coding_read_only_tool_count';
		$weight = $this->get_main_coding_read_only_tool_weight( $name, $args );
		$count  = isset( $tool_call_counts[ $key ] ) ? intval( $tool_call_counts[ $key ] ) + $weight : $weight;
		$tool_call_counts[ $key ] = $count;

		$terminal_limit = max( self::MAIN_CODING_READ_ONLY_TOOL_LIMIT + 1, (int) ceil( self::MAIN_CODING_READ_ONLY_TOOL_LIMIT * 1.5 ) );
		if ( $count >= $terminal_limit ) {
			return array(
				'ok'                  => false,
				'error'               => 'Loop guard: main-coding used ' . $count . ' read-only inspection tool calls without making/applying a concrete edit. Stop reading. Use existing context to call main_coding_workspace_prepare, edit an existing work copy, call main_coding_workspace_apply, or report the exact blocker.',
				'agent_loop_terminal' => true,
				'stopped_by_loop_guard' => true,
				'guard'               => 'main_coding_read_only_tool_limit',
				'read_only_count'     => $count,
				'read_only_limit'     => self::MAIN_CODING_READ_ONLY_TOOL_LIMIT,
				'required_next_tools' => array( 'main_coding_workspace_prepare', 'main_coding_shell_run', 'main_coding_workspace_apply' ),
				'reply'               => 'Zaustavljam ovaj coding turn: previse read-only pregleda bez konkretnog edit/apply koraka. Sledeci odgovor mora da koristi vec procitane podatke, napravi work-copy izmenu, apply-uje postojece izmene ili prijavi konkretan blocker.',
			);
		}

		if ( $count >= self::MAIN_CODING_READ_ONLY_TOOL_LIMIT && empty( $tool_call_counts['main_coding_read_only_tool_warned'] ) ) {
			$tool_call_counts['main_coding_read_only_tool_warned'] = true;
			return array(
				'ok'                  => false,
				'error'               => 'Coding checkpoint: ' . $count . ' read-only inspection calls done in this turn. Switch to action — main_coding_workspace_prepare, edit an existing work copy, main_coding_workspace_apply, or report a concrete blocker. Reuse already collected evidence rather than re-reading.',
				'stopped_by_loop_guard' => true,
				'guard'               => 'main_coding_read_only_tool_warning',
				'read_only_count'     => $count,
				'read_only_limit'     => self::MAIN_CODING_READ_ONLY_TOOL_LIMIT,
				'required_next_tools' => array( 'main_coding_workspace_prepare', 'main_coding_shell_run', 'main_coding_workspace_apply' ),
			);
		}

		// Soft nudge: track the threshold for telemetry but do not block the tool call.
		if ( $count >= self::MAIN_CODING_READ_ONLY_TOOL_NUDGE_LIMIT && empty( $tool_call_counts['main_coding_read_only_tool_nudged'] ) ) {
			$tool_call_counts['main_coding_read_only_tool_nudged'] = true;
		}

		return null;
	}

	private function get_main_coding_read_only_tool_weight( $name, $args ) {
		$name = 'code_read' === (string) $name ? 'file_read' : (string) $name;
		if ( 'batch_read' !== $name ) {
			return 1;
		}

		$args  = is_array( $args ) ? $args : array();
		$reads = isset( $args['reads'] ) && is_array( $args['reads'] ) ? count( $args['reads'] ) : 1;
		return max( 1, min( 8, intval( $reads ) ) );
	}

	private function check_main_coding_write_progress_guard( $name, $args, &$tool_call_counts ) {
		$name = (string) $name;
		$verification_tools = array(
			'main_coding_git_diff',
			'main_coding_lint_check',
			'main_coding_test_run',
			'main_coding_workspace_apply',
			'main_coding_workspace_rollback',
		);

		if ( 'main_coding_workspace_prepare' === $name ) {
			if ( ! empty( $tool_call_counts['main_coding_workspace_prepared'] ) && empty( $tool_call_counts['main_coding_first_write_seen'] ) ) {
				$prepare_count = isset( $tool_call_counts['main_coding_duplicate_prepare_count'] )
					? intval( $tool_call_counts['main_coding_duplicate_prepare_count'] ) + 1
					: 1;
				$tool_call_counts['main_coding_duplicate_prepare_count'] = $prepare_count;
				return array(
					'ok'                  => false,
					'error'               => 'Loop guard: a main_coding_workspace_prepare operation is already active and no work-copy edit has happened yet. Do not prepare another duplicate workspace. Inspect the existing operation_id from the previous tool result, edit its work_path files, then run main_coding_git_diff/main_coding_lint_check and main_coding_workspace_apply.',
					'stopped_by_loop_guard' => true,
					'guard'               => 'main_coding_duplicate_workspace_prepare',
					'required_next_tools' => array( 'main_coding_shell_run', 'main_coding_git_diff', 'main_coding_lint_check', 'main_coding_workspace_apply' ),
				);
			}

			$tool_call_counts['main_coding_workspace_prepared'] = true;
			$tool_call_counts['main_coding_first_write_seen']   = false;
			$tool_call_counts['main_coding_post_prepare_steps'] = 0;
			$tool_call_counts['main_coding_post_prepare_nudged'] = false;
			$tool_call_counts['main_coding_post_prepare_warned'] = false;
			$tool_call_counts['main_coding_post_write_steps']   = 0;
			$tool_call_counts['main_coding_post_write_soft_warned'] = false;
			$tool_call_counts['main_coding_post_write_hard_warned'] = false;
			return null;
		}

		if ( empty( $tool_call_counts['main_coding_workspace_prepared'] ) ) {
			return null;
		}

		if ( in_array( $name, array( 'main_coding_workspace_apply', 'main_coding_workspace_rollback' ), true ) ) {
			$tool_call_counts['main_coding_first_write_seen'] = false;
			$tool_call_counts['main_coding_workspace_prepared'] = false;
			$tool_call_counts['main_coding_post_prepare_steps'] = 0;
			$tool_call_counts['main_coding_post_prepare_nudged'] = false;
			$tool_call_counts['main_coding_post_prepare_warned'] = false;
			$tool_call_counts['main_coding_post_write_steps'] = 0;
			$tool_call_counts['main_coding_post_write_soft_warned'] = false;
			$tool_call_counts['main_coding_post_write_hard_warned'] = false;
			return null;
		}

		$is_write_op = ( 'main_coding_file_edit' === $name && empty( $args['dry_run'] ) ) || ( 'main_coding_shell_run' === $name && ! $this->is_main_coding_shell_read_only_call( $args ) );
		if ( $is_write_op && empty( $tool_call_counts['main_coding_first_write_seen'] ) ) {
			$tool_call_counts['main_coding_first_write_seen'] = true;
			$tool_call_counts['main_coding_post_prepare_steps'] = 0;
			$tool_call_counts['main_coding_post_prepare_nudged'] = false;
			$tool_call_counts['main_coding_post_prepare_warned'] = false;
			$tool_call_counts['main_coding_post_write_steps'] = 0;
			$tool_call_counts['main_coding_post_write_soft_warned'] = false;
			$tool_call_counts['main_coding_post_write_hard_warned'] = false;
			return null;
		}

		if ( ! empty( $tool_call_counts['main_coding_first_write_seen'] ) ) {
			if ( in_array( $name, $verification_tools, true ) ) {
				$tool_call_counts['main_coding_first_write_seen'] = false;
				$tool_call_counts['main_coding_post_write_steps'] = 0;
				$tool_call_counts['main_coding_post_write_soft_warned'] = false;
				$tool_call_counts['main_coding_post_write_hard_warned'] = false;
				return null;
			}
			return $this->advance_main_coding_post_write_guard( $tool_call_counts );
		}

		if ( ! empty( $tool_call_counts['main_coding_workspace_prepared'] ) ) {
			return $this->advance_main_coding_post_prepare_guard( $tool_call_counts );
		}

		return null;
	}

	private function advance_main_coding_post_prepare_guard( &$tool_call_counts ) {
		$steps = isset( $tool_call_counts['main_coding_post_prepare_steps'] )
			? intval( $tool_call_counts['main_coding_post_prepare_steps'] ) + 1
			: 1;
		$tool_call_counts['main_coding_post_prepare_steps'] = $steps;

		$terminal_threshold = self::MAIN_CODING_POST_PREPARE_STEP_LIMIT + 1;
		if ( $steps >= $terminal_threshold ) {
			return array(
				'ok'                  => false,
				'error'               => 'Loop guard: after workspace_prepare, main-coding exceeded ' . $terminal_threshold . ' tool steps without editing a work copy, applying, or rolling back. Stop reading and continue from the existing prepared workspace.',
				'agent_loop_terminal' => true,
				'stopped_by_loop_guard' => true,
				'guard'               => 'main_coding_post_prepare_step_limit',
				'post_prepare_steps'  => $steps,
				'step_limit'          => self::MAIN_CODING_POST_PREPARE_STEP_LIMIT,
				'required_next_tools' => array( 'main_coding_shell_run', 'main_coding_workspace_apply', 'main_coding_workspace_rollback' ),
				'reply'               => 'Zaustavljam ovaj coding turn: workspace je pripremljen, ali nema edit/apply/rollback koraka. Sledeci odgovor mora da koristi postojeci operation_id i work_path fajlove.',
			);
		}

		if ( $steps >= self::MAIN_CODING_POST_PREPARE_STEP_LIMIT && empty( $tool_call_counts['main_coding_post_prepare_warned'] ) ) {
			$tool_call_counts['main_coding_post_prepare_warned'] = true;
			return array(
				'ok'                  => false,
				'error'               => 'Coding checkpoint: workspace already prepared, ' . $steps . ' follow-up steps done. Time to edit a work_path file, main_coding_workspace_apply, or main_coding_workspace_rollback. Reuse the existing operation_id; do not prepare a new workspace and stop reading unrelated files.',
				'stopped_by_loop_guard' => true,
				'guard'               => 'main_coding_post_prepare_warning',
				'post_prepare_steps'  => $steps,
				'step_limit'          => self::MAIN_CODING_POST_PREPARE_STEP_LIMIT,
				'required_next_tools' => array( 'main_coding_shell_run', 'main_coding_workspace_apply', 'main_coding_workspace_rollback' ),
			);
		}

		// Soft nudge: track the threshold for telemetry but do not block the tool call.
		if ( $steps >= self::MAIN_CODING_POST_PREPARE_NUDGE_STEP_LIMIT && empty( $tool_call_counts['main_coding_post_prepare_nudged'] ) ) {
			$tool_call_counts['main_coding_post_prepare_nudged'] = true;
		}

		return null;
	}

	private function advance_main_coding_post_write_guard( &$tool_call_counts ) {
		$steps = isset( $tool_call_counts['main_coding_post_write_steps'] )
			? intval( $tool_call_counts['main_coding_post_write_steps'] ) + 1
			: 1;
		$tool_call_counts['main_coding_post_write_steps'] = $steps;

		$soft_threshold     = max( 1, (int) ceil( self::MAIN_CODING_POST_WRITE_STEP_LIMIT * 0.5 ) );
		$hard_threshold     = self::MAIN_CODING_POST_WRITE_STEP_LIMIT;
		$terminal_threshold = max( $hard_threshold + 1, (int) ceil( self::MAIN_CODING_POST_WRITE_STEP_LIMIT * 1.5 ) );

		if ( $steps >= $terminal_threshold ) {
			return array(
				'ok'                  => false,
				'error'               => 'Loop guard: after workspace_prepare and the first work-copy write, main-coding exceeded ' . $terminal_threshold . ' non-verification tool steps without reaching main_coding_git_diff, main_coding_lint_check, main_coding_test_run, main_coding_workspace_apply, or a concrete blocker.',
				'agent_loop_terminal' => true,
				'stopped_by_loop_guard' => true,
				'guard'               => 'main_coding_post_write_step_limit',
				'post_write_steps'    => $steps,
				'step_limit'          => self::MAIN_CODING_POST_WRITE_STEP_LIMIT,
				'terminal_threshold'  => $terminal_threshold,
				'required_next_tools' => array( 'main_coding_git_diff', 'main_coding_lint_check', 'main_coding_workspace_apply' ),
				'reply'               => 'Zaustavljam ovaj coding turn: work copy je vec menjan, ali runtime nije presao na diff/lint/test/apply ni posle prosirenog sigurnosnog limita. Sledeci odgovor mora da proveri postojece work copy fajlove, pokrene diff/lint, pa apply ili prijavi konkretan blocker.',
			);
		}

		if ( $steps >= $hard_threshold && empty( $tool_call_counts['main_coding_post_write_hard_warned'] ) ) {
			$tool_call_counts['main_coding_post_write_hard_warned'] = true;
			return array(
				'ok'                  => false,
				'error'               => 'Loop guard hard stop: work copy has already been modified and ' . $steps . ' non-verification tool steps have run. Do not inspect further. Run main_coding_git_diff, main_coding_lint_check, optionally main_coding_test_run, then main_coding_workspace_apply or report the exact blocker.',
				'stopped_by_loop_guard' => true,
				'guard'               => 'main_coding_post_write_hard_warning',
				'post_write_steps'    => $steps,
				'step_limit'          => self::MAIN_CODING_POST_WRITE_STEP_LIMIT,
				'required_next_tools' => array( 'main_coding_git_diff', 'main_coding_lint_check', 'main_coding_workspace_apply' ),
			);
		}

		if ( $steps >= $soft_threshold && empty( $tool_call_counts['main_coding_post_write_soft_warned'] ) ) {
			$tool_call_counts['main_coding_post_write_soft_warned'] = true;
			return array(
				'ok'                  => false,
				'error'               => 'Loop guard soft warning: work copy has already been modified and ' . $steps . ' non-verification tool steps have run. Continue only if needed, but plan the next verification checkpoint: main_coding_git_diff, main_coding_lint_check, optional main_coding_test_run, then main_coding_workspace_apply.',
				'stopped_by_loop_guard' => true,
				'guard'               => 'main_coding_post_write_soft_warning',
				'post_write_steps'    => $steps,
				'step_limit'          => self::MAIN_CODING_POST_WRITE_STEP_LIMIT,
				'required_next_tools' => array( 'main_coding_git_diff', 'main_coding_lint_check', 'main_coding_workspace_apply' ),
			);
		}

		return null;
	}

	private function is_main_coding_shell_read_only_call( $args ) {
		if ( ! is_array( $args ) ) {
			return false;
		}

		$command = isset( $args['command'] ) ? trim( (string) $args['command'] ) : '';
		if ( '' === $command && isset( $args['argv'] ) && is_array( $args['argv'] ) ) {
			$command = implode( ' ', array_map( 'strval', $args['argv'] ) );
		}

		if ( '' === $command ) {
			return false;
		}

		$lower = strtolower( $command );
		$write_patterns = array(
			'/>|>>|\btee\b|\bsed\s+-i\b|\bperl\s+-pi\b|\bwp\s+option\s+update\b/',
			'/\b(cp|mv|rm|touch|mkdir|chmod|chown|composer|npm|yarn|pnpm)\b/',
			'/\b(set-content|add-content|out-file|new-item|remove-item|move-item|copy-item)\b/',
			'/write_text\s*\(|file_put_contents\s*\(|fwrite\s*\(|rename\s*\(|copy\s*\(|unlink\s*\(/',
			'/open\s*\([^)]*[\'"][wa+][\'"]/',
		);

		foreach ( $write_patterns as $pattern ) {
			if ( preg_match( $pattern, $lower ) ) {
				return false;
			}
		}

		return true;
	}

	private function is_main_coding_read_only_tool_call( $name, $args ) {
		$name = (string) $name;
		if ( 'code_read' === $name ) {
			$name = 'file_read';
		}
		if ( 'main_coding_shell_run' === $name ) {
			return $this->is_main_coding_shell_read_only_call( $args );
		}
		if ( 'main_coding_file_edit' === $name ) {
			return ! empty( $args['dry_run'] );
		}

		return in_array(
			$name,
			array(
				'code_search',
				'file_read',
				'file_list',
				'batch_read',
				'code_scan_run',
				'main_coding_edit_dry_run',
			),
			true
		);
	}

	private function extract_tool_call_file_range( $name, $args ) {
		if ( ! is_array( $args ) ) {
			return null;
		}
		$name = 'code_read' === (string) $name ? 'file_read' : (string) $name;

		if ( 'file_read' === $name ) {
			$path  = isset( $args['path'] ) ? trim( (string) $args['path'] ) : '';
			$start = isset( $args['start_line'] ) ? intval( $args['start_line'] ) : 0;
			$limit = isset( $args['limit'] ) ? intval( $args['limit'] ) : 0;
			if ( '' === $path || $start <= 0 || $limit <= 0 ) {
				return null;
			}
			return array(
				'path'  => $path,
				'start' => $start,
				'end'   => $start + $limit - 1,
			);
		}

		if ( 'code_scan_run' === $name ) {
			$argv = isset( $args['argv'] ) && is_array( $args['argv'] ) ? array_values( $args['argv'] ) : array();
			if ( count( $argv ) < 3 ) {
				return null;
			}
			$cmd = strtolower( basename( (string) $argv[0] ) );
			if ( 'sed' !== $cmd ) {
				return null;
			}
			$quiet  = false;
			$script = '';
			$path   = '';
			for ( $i = 1; $i < count( $argv ); $i++ ) {
				$arg = (string) $argv[ $i ];
				if ( '-n' === $arg ) {
					$quiet = true;
					continue;
				}
				if ( '' === $script && '' !== $arg && 0 !== strpos( $arg, '-' ) ) {
					$script = $arg;
					continue;
				}
				if ( '' !== $script && '' === $path && '' !== $arg ) {
					$path = $arg;
				}
			}
			if ( ! $quiet || '' === $script || '' === $path || ! preg_match( '/^(\d+),(\d+)p$/', $script, $m ) ) {
				return null;
			}
			return array(
				'path'  => $path,
				'start' => intval( $m[1] ),
				'end'   => intval( $m[2] ),
			);
		}

		return null;
	}

	private function resolve_model_request_timeout( $options ) {
		$timeout = isset( $options['model_request_timeout'] ) ? max( 10, intval( $options['model_request_timeout'] ) ) : self::DEFAULT_TIMEOUT;
		$remaining_seconds = 0;
		$soft_deadline = isset( $options['soft_deadline'] ) ? intval( $options['soft_deadline'] ) : 0;
		if ( $soft_deadline > 0 ) {
			$remaining_seconds = $soft_deadline - time();
		}

		$sapi = function_exists( 'php_sapi_name' ) ? (string) php_sapi_name() : '';
		$is_litespeed = ( '' !== $sapi && false !== stripos( $sapi, 'litespeed' ) );

		return self::resolve_model_request_timeout_from_remaining_seconds( $remaining_seconds, $timeout, $is_litespeed );
	}

	public static function build_model_request_timeout_checkpoint_sample( $remaining_seconds ) {
		$remaining_seconds = intval( $remaining_seconds );
		$resolved_timeout = self::resolve_model_request_timeout_from_remaining_seconds( $remaining_seconds, self::DEFAULT_TIMEOUT, true );

		return array(
			'remaining_seconds'                    => $remaining_seconds,
			'resolved_timeout'                      => $resolved_timeout,
			'would_checkpoint_before_model_request' => self::would_checkpoint_before_model_request_from_remaining_seconds( $remaining_seconds, $resolved_timeout ),
		);
	}

	private static function resolve_model_request_timeout_from_remaining_seconds( $remaining_seconds, $model_request_timeout = null, $is_litespeed = null ) {
		$timeout = null === $model_request_timeout ? self::DEFAULT_TIMEOUT : max( 10, intval( $model_request_timeout ) );
		if ( null === $is_litespeed ) {
			$sapi = function_exists( 'php_sapi_name' ) ? (string) php_sapi_name() : '';
			$is_litespeed = ( '' !== $sapi && false !== stripos( $sapi, 'litespeed' ) );
		}
		if ( $is_litespeed ) {
			$timeout = min( $timeout, self::LITESPEED_MODEL_REQUEST_TIMEOUT );
		}

		$remaining_seconds = intval( $remaining_seconds );
		if ( $remaining_seconds > 0 ) {
			if ( $remaining_seconds <= self::MODEL_REQUEST_SOFT_DEADLINE_BUFFER ) {
				return max( 10, min( $timeout, 30 ) );
			}

			$candidate = max( 30, $remaining_seconds - self::MODEL_REQUEST_SOFT_DEADLINE_BUFFER );
			$checkpoint_floor_threshold = self::MODEL_REQUEST_CHECKPOINT_SECONDS + max( 1, self::MODEL_REQUEST_SOFT_DEADLINE_BUFFER - 10 );
			if ( $candidate < self::MODEL_REQUEST_CHECKPOINT_SECONDS && $remaining_seconds >= $checkpoint_floor_threshold ) {
				$candidate = self::MODEL_REQUEST_CHECKPOINT_SECONDS;
			}
			$timeout = min( $timeout, $candidate );
		}

		return max( 10, intval( $timeout ) );
	}

	private static function would_checkpoint_before_model_request_from_remaining_seconds( $remaining_seconds, $resolved_timeout ) {
		$remaining_seconds = intval( $remaining_seconds );
		if ( $remaining_seconds <= 0 ) {
			return false;
		}
		if ( $remaining_seconds <= self::MODEL_REQUEST_CHECKPOINT_SECONDS ) {
			return true;
		}

		return intval( $resolved_timeout ) < self::MODEL_REQUEST_CHECKPOINT_SECONDS;
	}

	/* Error recovery */

	/**
	 * Handle a streaming error and determine the recovery action.
	 *
	 * @param string                $error_message         The error message.
	 * @param int                   $http_status           HTTP status code (0 if unknown).
	 * @param Niwa_AI_Agent_Context $agent_context         Agent context (for compaction).
	 * @param int                   $attempt               Zero-based attempt counter.
	 * @param int                   $compaction_attempts   Number of compaction attempts so far.
	 * @param callable|null         $run_progress_callback Progress callback.
	 * @param string                $mode                  Runtime mode.
	 * @param array                 $options               Runtime options.
	 * @param array                 $stream_result         Raw stream result.
	 *
	 * @return string Recovery action: 'retry', 'compact', or 'fail'.
	 */
	private function handle_stream_error( $error_message, $http_status, $agent_context, $attempt, $compaction_attempts, $run_progress_callback = null, $mode = 'main', $options = array(), $stream_result = array() ) {
		$error_type = $this->error_recovery->classify_error( $error_message, $http_status );

		// Log the error
		$log_line = $this->error_recovery->format_error_for_log( $error_type, $error_message, $attempt );
		if ( function_exists( 'error_log' ) ) {
			error_log( $log_line ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
		}

		// Emit error event
		$this->emit(
			Niwa_AI_Agent_Event_Emitter::EVENT_ERROR,
			array(
				'error_type'  => $error_type,
				'message'     => $error_message,
				'http_status' => $http_status,
				'attempt'     => $attempt,
				'fatal'       => false,
			),
			$run_progress_callback
		);

		// Context overflow -> compaction
		if ( $this->error_recovery->should_compact( $error_type ) ) {
			if ( $compaction_attempts >= self::MAX_COMPACTION_ATTEMPTS ) {
				return 'fail';
			}

			$this->emit(
				Niwa_AI_Agent_Event_Emitter::EVENT_COMPACTION_START,
				array(
					'reason'  => 'context_overflow_error',
					'attempt' => $compaction_attempts + 1,
				),
				$run_progress_callback
			);

			$compact_result = Niwa_AI_Context_Window_Guard::compact_with_retry(
				$agent_context->get_messages(),
				$agent_context->get_system_prompt(),
				$agent_context->get_model(),
				$this->ai_client,
				$agent_context->get_auth_mode(),
				$agent_context->get_creds(),
				$agent_context->get_tools(),
				$agent_context->get_turn_frame()
			);

			if ( ! empty( $compact_result['messages'] ) ) {
				$agent_context->set_messages( $compact_result['messages'] );
			}

			$this->emit(
				Niwa_AI_Agent_Event_Emitter::EVENT_COMPACTION_END,
				array(
					'success'  => ! empty( $compact_result['success'] ),
					'attempts' => isset( $compact_result['attempts'] ) ? $compact_result['attempts'] : 0,
				),
				$run_progress_callback
			);

			if ( ! empty( $compact_result['success'] ) ) {
				return 'compact';
			}

			return 'fail';
		}

		// Retryable errors
		if ( Niwa_AI_Error_Recovery::ERROR_TIMEOUT === $error_type ) {
			$this->apply_stream_timeout_runtime_correction(
				$agent_context,
				$attempt,
				$mode,
				$options,
				$stream_result,
				$run_progress_callback
			);
			if ( $this->should_checkpoint_before_model_request( $options ) ) {
				return 'checkpoint';
			}
		}

		if ( $attempt < self::MAX_STREAM_RETRIES && $this->error_recovery->should_retry( $error_type, $attempt ) ) {
			$delay_ms = $this->error_recovery->sleep_for_retry( $error_type, $attempt );

			$this->emit(
				Niwa_AI_Agent_Event_Emitter::EVENT_ERROR,
				array(
					'error_type' => $error_type,
					'message'    => 'Retrying after ' . $delay_ms . 'ms delay.',
					'attempt'    => $attempt + 1,
					'fatal'      => false,
				),
				$run_progress_callback
			);

			return 'retry';
		}

		// Non-retryable
		return 'fail';
	}

	private function should_checkpoint_before_model_request( $options ) {
		$soft_deadline = isset( $options['soft_deadline'] ) ? intval( $options['soft_deadline'] ) : 0;
		if ( $soft_deadline <= 0 ) {
			return false;
		}

		$remaining = $soft_deadline - time();
		$resolved_timeout = $this->resolve_model_request_timeout( $options );

		return self::would_checkpoint_before_model_request_from_remaining_seconds( $remaining, $resolved_timeout );
	}

	private function build_soft_deadline_checkpoint_result( $agent_context, $context, $turn_id, $model, $iteration_count, $total_usage, $error_message, $run_progress_callback = null ) {
		$this->emit(
			Niwa_AI_Agent_Event_Emitter::EVENT_AGENT_END,
			array(
				'turn_id'          => $turn_id,
				'reason'            => 'cancelled',
				'cancelled_reason' => 'soft_deadline',
				'iterations'       => $iteration_count,
			),
			$run_progress_callback
		);

		return array(
			'success'          => false,
			'cancelled'        => true,
			'cancelled_reason' => 'soft_deadline',
			'error'            => (string) $error_message,
			'reply'            => '',
			'turn_id'          => $turn_id,
			'model'            => $model,
			'iterations'       => $iteration_count,
			'usage'            => $total_usage,
			'context_snapshot' => is_object( $agent_context ) && method_exists( $agent_context, 'serialize_for_continuation' )
				? $agent_context->serialize_for_continuation()
				: array(),
		);
	}

	private function apply_stream_timeout_runtime_correction( $agent_context, $attempt, $mode, $options, $stream_result, $run_progress_callback = null ) {
		$bytes_read      = is_array( $stream_result ) && isset( $stream_result['bytes_read'] ) ? intval( $stream_result['bytes_read'] ) : 0;
		$timeout_seconds = is_array( $stream_result ) && isset( $stream_result['timeout_seconds'] ) ? intval( $stream_result['timeout_seconds'] ) : 0;
		$soft_deadline   = isset( $options['soft_deadline'] ) ? intval( $options['soft_deadline'] ) : 0;
		$remaining       = $soft_deadline > 0 ? max( 0, $soft_deadline - time() ) : 0;
		$model_before    = is_object( $agent_context ) && method_exists( $agent_context, 'get_model' ) ? (string) $agent_context->get_model() : '';
		$model_after     = $model_before;

		if ( is_object( $agent_context ) && method_exists( $agent_context, 'append_message' ) && ! $this->last_context_message_contains( $agent_context, '[RUNTIME STREAM TIMEOUT RECOVERY]' ) ) {
			$agent_context->append_message(
				array(
					'role'    => 'user',
					'content' => '[RUNTIME STREAM TIMEOUT RECOVERY] The previous model stream timed out after receiving partial output but no usable message_end/tool call. On this retry, do not narrate, plan, or print pseudo tool calls. Emit exactly one real function/tool call immediately. For main-coding work, use existing context and any prepared operation_id/work_path values; write/apply/rollback instead of reading more files. If no tool call is possible, answer in <=3 short sentences with the exact blocker.',
				)
			);
		}

		$this->emit(
			Niwa_AI_Agent_Event_Emitter::EVENT_ERROR,
			array(
				'error_type'      => 'stream_timeout_recovery',
				'message'         => 'Injected stream-timeout recovery instruction before retry.',
				'attempt'         => intval( $attempt ),
				'mode'            => (string) $mode,
				'bytes_read'      => $bytes_read,
				'timeout_seconds' => $timeout_seconds,
				'seconds_remaining' => $remaining,
				'model_before'    => $model_before,
				'model_after'     => $model_after,
				'fatal'           => false,
			),
			$run_progress_callback
		);
	}

	private function last_context_message_contains( $agent_context, $needle ) {
		if ( ! is_object( $agent_context ) || ! method_exists( $agent_context, 'get_messages' ) ) {
			return false;
		}

		$messages = $agent_context->get_messages();
		if ( empty( $messages ) || ! is_array( $messages ) ) {
			return false;
		}

		$last = end( $messages );
		if ( ! is_array( $last ) || ! isset( $last['content'] ) ) {
			return false;
		}

		$content = is_string( $last['content'] ) ? $last['content'] : wp_json_encode( $last['content'] );
		return false !== strpos( (string) $content, (string) $needle );
	}

	private function maybe_inject_soft_deadline_warning( $agent_context, $soft_deadline, &$warning_sent, $turn_id, $run_progress_callback = null ) {
		if ( $warning_sent || $soft_deadline <= 0 ) {
			return;
		}

		$remaining = intval( $soft_deadline ) - time();
		if ( $remaining > self::SOFT_DEADLINE_WARNING_SECONDS || $remaining <= 0 ) {
			return;
		}

		$warning_sent = true;
		$this->emit(
			Niwa_AI_Agent_Event_Emitter::EVENT_ERROR,
			array(
				'turn_id'           => $turn_id,
				'error_type'        => 'soft_deadline_warning',
				'message'           => 'Soft deadline is close. Finish verification/apply or produce a concrete continuation summary.',
				'seconds_remaining' => $remaining,
				'fatal'             => false,
			),
			$run_progress_callback
		);

		if ( is_object( $agent_context ) && method_exists( $agent_context, 'append_message' ) ) {
			$agent_context->append_message(
				array(
					'role'    => 'user',
					'content' => '[RUNTIME DEADLINE WARNING] About ' . max( 1, $remaining ) . ' seconds remain in this worker turn. This notice is scoped only to this worker. Do not open new investigation branches. Finish required verification/apply if safe, or allow the runtime continuation to resume with a fresh budget.',
					'niwa_runtime_control' => 'soft_deadline_warning',
					'ephemeral'            => true,
				)
			);
		}
	}

	/* Message injection */

	/**
	 * Inject messages into the agent context.
	 *
	 * Used for both steering messages (mid-loop injection) and follow-up
	 * messages (post-inner-loop injection). Messages are appended to the
	 * conversation in the order provided.
	 *
	 * @param array                 $messages      Array of message arrays to inject.
	 * @param Niwa_AI_Agent_Context $agent_context The agent context to modify.
	 *
	 * @return void
	 */
	private function inject_messages( $messages, $agent_context ) {
		if ( ! is_array( $messages ) || empty( $messages ) ) {
			return;
		}

		foreach ( $messages as $message ) {
			if ( ! is_array( $message ) || ! isset( $message['role'] ) ) {
				continue;
			}

			$agent_context->append_message( $message );
		}
	}

	/* Message providers */

	/**
	 * Get steering messages from the provider callback.
	 *
	 * @return array Array of message arrays, or empty array.
	 */
	private function get_steering_messages() {
		return $this->get_messages_from_provider( $this->steering_messages_provider );
	}

	/**
	 * Get follow-up messages from the provider callback.
	 *
	 * @return array Array of message arrays, or empty array.
	 */
	private function get_follow_up_messages() {
		return $this->get_messages_from_provider( $this->follow_up_messages_provider );
	}

	/**
	 * Get normalized messages from a provider callback.
	 *
	 * @param callable|null $provider Message provider callback.
	 * @return array Array of message arrays, or empty array.
	 */
	private function get_messages_from_provider( $provider ) {
		if ( ! is_callable( $provider ) ) {
			return array();
		}

		$messages = call_user_func( $provider );

		return is_array( $messages ) ? $messages : array();
	}

	/* Cancellation */

	/**
	 * Check whether the agent has been cancelled.
	 *
	 * @param callable|null $cancel_probe The cancel probe callable to check.
	 *
	 * @return bool True if the agent should stop.
	 */
	private function is_cancelled( $cancel_probe = null ) {
		if ( ! is_callable( $cancel_probe ) ) {
			return false;
		}

		return ! empty( call_user_func( $cancel_probe ) );
	}

	/* Event emission */

	/**
	 * Emit an event through the event emitter and map to legacy progress callback.
	 *
	 * Forwards all events to the event emitter, and additionally translates
	 * event types into legacy progress_callback calls for backward compatibility
	 * with code that expects the old Turn Engine's progress events.
	 *
	 * Event type mapping:
	 *   - turn_start            -> progress_callback('turn_started', ...)
	 *   - turn_end              -> progress_callback('turn_ended', ...)
	 *   - tool_execution_start  -> progress_callback('tool_call_started', ...)
	 *   - tool_execution_end    -> progress_callback('tool_call_finished', ...)
	 *   - message_start         -> progress_callback('model_request', {stage: 'followup', ...})
	 *   - agent_start           -> progress_callback('turn_started', ...)
	 *   - agent_end             -> progress_callback('turn_ended', ...)
	 *
	 * @param string        $event                 Event type constant.
	 * @param array         $payload               Event data.
	 * @param callable|null $run_progress_callback  Override progress callback for this run.
	 *
	 * @return void
	 */
	private function emit( $event, $payload = array(), $run_progress_callback = null ) {
		// Always forward to the event emitter.
		$this->event_emitter->emit( $event, $payload );

		// Map to legacy progress callback.
		$callback = is_callable( $run_progress_callback ) ? $run_progress_callback : $this->progress_callback;

		if ( ! is_callable( $callback ) ) {
			return;
		}

		$legacy_event   = null;
		$legacy_payload = $payload;

		switch ( $event ) {
			case Niwa_AI_Agent_Event_Emitter::EVENT_AGENT_START:
				$legacy_event = 'turn_started';
				break;

			case Niwa_AI_Agent_Event_Emitter::EVENT_AGENT_END:
				$legacy_event = 'turn_ended';
				break;

			case Niwa_AI_Agent_Event_Emitter::EVENT_TURN_START:
				$legacy_event = 'turn_started';
				break;

			case Niwa_AI_Agent_Event_Emitter::EVENT_TURN_END:
				$legacy_event = 'turn_ended';
				break;

			case Niwa_AI_Agent_Event_Emitter::EVENT_MESSAGE_START:
				$legacy_event   = 'model_request';
				$legacy_payload = array_merge( $payload, array( 'stage' => 'followup' ) );
				break;

			case Niwa_AI_Agent_Event_Emitter::EVENT_MESSAGE_END:
				$legacy_event   = 'model_response';
				$legacy_payload = array_merge( $payload, array( 'stage' => 'complete' ) );
				break;

			case Niwa_AI_Agent_Event_Emitter::EVENT_TOOL_EXECUTION_START:
				$legacy_event   = 'tool_call_started';
				$legacy_payload = array(
					'tool' => isset( $payload['name'] ) ? $payload['name'] : '',
				);
				break;

			case Niwa_AI_Agent_Event_Emitter::EVENT_TOOL_EXECUTION_END:
				$legacy_event   = 'tool_call_finished';
				$legacy_payload = array(
					'tool' => isset( $payload['name'] ) ? $payload['name'] : '',
				);
				break;

			case Niwa_AI_Agent_Event_Emitter::EVENT_ERROR:
				$legacy_event   = 'error';
				$legacy_payload = array(
					'message' => isset( $payload['message'] ) ? $payload['message'] : 'Unknown error',
				);
				break;
		}

		if ( null !== $legacy_event ) {
			$this->emit_progress_callback( $callback, (string) $legacy_event, $legacy_payload );
		}
	}

	/* Helpers */

	/** Emit a legacy progress callback with normalized payload. */
	private function emit_progress_callback( $callback, $event, $payload = array() ) {
		if ( ! is_callable( $callback ) ) {
			return;
		}

		call_user_func( $callback, (string) $event, is_array( $payload ) ? $payload : array() );
	}

	/* Accessors (for testing and composition) */

	/**
	 * Get the event emitter instance.
	 *
	 * @return Niwa_AI_Agent_Event_Emitter
	 */
	public function get_event_emitter() {
		return $this->event_emitter;
	}

	/**
	 * Get the error recovery instance.
	 *
	 * @return Niwa_AI_Error_Recovery
	 */
	public function get_error_recovery() {
		return $this->error_recovery;
	}

	/**
	 * Get the context guard instance.
	 *
	 * @return Niwa_AI_Context_Window_Guard
	 */
	public function get_context_guard() {
		return $this->context_guard;
	}

	/**
	 * Get the message validator instance.
	 *
	 * @return Niwa_AI_Message_Validator
	 */
	public function get_message_validator() {
		return $this->message_validator;
	}

}
