File "ToolCallProcessor.php"

Full Path: /home/buyiwexj/public_html/wp-content/plugins/wpforms-lite/src/Integrations/AI/Admin/Ajax/Chat/ToolCallProcessor.php
File size: 20.06 KB
MIME-type: text/x-php
Charset: utf-8

<?php

namespace WPForms\Integrations\AI\Admin\Ajax\Chat;

use WPForms\Integrations\AI\Admin\Chat\ScopeRegistry;

/**
 * Tool-call normalization, dispatch, streaming frames, and diagnostics.
 *
 * Extracted from the Chat AJAX handler to keep each class under the 20-method threshold.
 * Turn-scoped — created once per round-loop invocation with the dependencies
 * needed for the answer-round tool-call lifecycle.
 *
 * @since 2.0.0
 */
class ToolCallProcessor {

	/**
	 * Maximum tool calls accepted from a single round.
	 *
	 * Surplus calls beyond this cap are synthesized as `too_many_calls` tool errors
	 * so the model sees explicit feedback and can re-emit ignored calls in a
	 * subsequent round.
	 *
	 * @since 2.0.0
	 */
	private const MAX_TOOL_CALLS_PER_ROUND = 10;

	/**
	 * Sentinel key returned by `execute_tool_call()` when the model calls the
	 * synthetic `reclassify` tool. The round loop detects this and re-runs the
	 * classify round server-side instead of terminating.
	 *
	 * @since 2.0.0
	 */
	public const RECLASSIFY_SENTINEL = '__reclassify';

	/**
	 * User-facing message when the model is stuck re-emitting an identical query.
	 *
	 * @since 2.0.0
	 */
	private const STUCK_LOOP_MESSAGE = 'I couldn\'t find anything matching that. Try broadening your filters or asking about a different metric.';

	/**
	 * Scope registry for tool resolution and label lookups.
	 *
	 * @since 2.0.0
	 *
	 * @var ScopeRegistry
	 */
	private $registry;

	/**
	 * Original request payload passed through to scope data sources.
	 *
	 * @since 2.0.0
	 *
	 * @var array
	 */
	private $payload;

	/**
	 * NDJSON frame sink for streaming tool-call brackets to the client.
	 *
	 * @since 2.0.0
	 *
	 * @var callable
	 */
	private $sink;

	/**
	 * Constructor.
	 *
	 * @since 2.0.0
	 *
	 * @param ScopeRegistry $registry Scope registry for tool resolution.
	 * @param array         $payload  Original request payload.
	 * @param callable      $sink     NDJSON frame sink.
	 */
	public function __construct( ScopeRegistry $registry, array $payload, callable $sink ) {

		$this->registry = $registry;
		$this->payload  = $payload;
		$this->sink     = $sink;
	}

	/**
	 * Process tool calls from an answer-round API response.
	 *
	 * @since 2.0.0
	 *
	 * @param array $response       Raw answer-round API response.
	 * @param array $state          State bag built by `process_answer_round()`.
	 * @param array $allowed_scopes Scope slugs pinned for this turn; calls outside are rejected.
	 *
	 * @return array Updated state bag.
	 */
	public function process( array $response, array $state, array $allowed_scopes ): array {

		$tool_calls = $this->normalize_tool_call_arguments( (array) ( $response['tool_calls'] ?? [] ) );

		if ( $tool_calls === [] ) {
			$state['terminal'] = $this->error( 'protocol', 'Middleware returned neither message nor tool_calls.' );

			return $state;
		}

		$split    = $this->enforce_tool_call_cap( $tool_calls );
		$accepted = $split['accepted'];
		$rejected = $split['rejected'];
		$set_hash = $this->build_tool_call_set_hash( $accepted );

		// Window dedup: catch both the consecutive (A→A) and the non-consecutive
		// (A→B→…→A) repeat patterns. Anything the model already executed this
		// turn is sitting in `$history` as tool results — re-emitting the same
		// `(name, args)` set is definitionally stuck.
		if ( $this->is_stuck_loop( $set_hash, $state['seen_set_hashes'] ) ) {
			$state['terminal'] = $this->success(
				[
					'message'   => [ 'text' => self::STUCK_LOOP_MESSAGE ],
					'sessionId' => $state['session_id'],
				]
			);

			return $state;
		}

		if ( $set_hash !== '' ) {
			$state['seen_set_hashes'][] = $set_hash;
		}

		// Record the tool calls actually dispatched this round (post-stuck-loop) for diagnostics.
		$state['round']['tools'] = $this->collect_tool_calls( $accepted );

		// Declare every emitted call — accepted and overflow alike. OpenAI rejects any
		// role:tool message whose tool_call_id is absent from the preceding assistant
		// message, so the overflow rejections appended below must each be matched by a
		// declaration here. Only `$accepted` is dispatched; the cap limits execution,
		// not acknowledgement.
		$state['history'][] = [
			'role'       => 'assistant',
			'content'    => null,
			'tool_calls' => $tool_calls,
		];

		$state['terminal'] = $this->dispatch_tool_calls( $accepted, $state['history'], $allowed_scopes );

		// Acknowledge overflow rejections unconditionally. Even when dispatch ends the
		// round early (a reclassify call sets the terminal), every overflow call declared
		// above still needs a matching result, and the model should learn it hit the cap.
		$this->append_overflow_messages( $state['history'], $rejected );

		return $state;
	}

	/**
	 * Emit a single tool-call NDJSON frame via the sink.
	 *
	 * @since 2.0.0
	 *
	 * @param string $type         Frame type — `tool_call_start` or `tool_call_end`.
	 * @param string $tool_slug    Scope slug (or raw name for synthetic tools).
	 * @param string $tool_call_id Unique call ID for frontend correlation.
	 * @param string $label        Spinner-copy label (only for `tool_call_start`).
	 */
	private function sink_tool_frame( string $type, string $tool_slug, string $tool_call_id, string $label = '' ): void {

		$frame = [
			'v'       => 1,
			'type'    => $type,
			'tool'    => $tool_slug,
			'call_id' => $tool_call_id,
		];

		if ( $label !== '' ) {
			$frame['label'] = $label;
		}

		( $this->sink )( $frame );
	}

	/**
	 * Execute a single tool call, emit framing events, and append the result to history.
	 *
	 * Returns a reclassify sentinel (`[ RECLASSIFY_SENTINEL => hint ]`) when the
	 * model calls the synthetic `reclassify` tool, otherwise null so the caller
	 * continues iterating.
	 *
	 * @since 2.0.0
	 *
	 * @param array $call           Tool call frame (`id`, `name`, `arguments`).
	 * @param array $history        Conversation history array (passed by reference).
	 * @param array $allowed_scopes Scope slugs pinned for this turn; calls outside are rejected.
	 *
	 * @return array|null Reclassify sentinel or null to continue.
	 */
	private function execute_tool_call( array $call, array &$history, array $allowed_scopes ): ?array {

		$tool_call_id = (string) ( $call['id'] ?? '' );
		$tool_name    = (string) ( $call['name'] ?? '' );
		$label        = $this->derive_tool_call_label( $call );
		$tool_slug    = $this->extract_tool_slug( $tool_name );

		// The synthetic `reclassify` tool is not scope-prefixed and is exempt from
		// the allow-list — it is the model's escape hatch for requesting a different
		// scope set. Returns a sentinel array so the round loop can re-classify
		// server-side instead of punting to the browser.
		if ( $tool_name === 'reclassify' ) {
			$this->sink_tool_frame( 'tool_call_start', $tool_slug, $tool_call_id, $label );
			$this->sink_tool_frame( 'tool_call_end', $tool_slug, $tool_call_id );

			$arguments = (array) ( $call['arguments'] ?? [] );
			$hint      = sanitize_text_field( (string) ( $arguments['hint'] ?? $arguments['reason'] ?? '' ) );

			// OpenAI requires a tool result for every tool_call in the assistant
			// message. Without this, the re-classify call sends incomplete history
			// and OpenAI rejects it with "No tool output found for function call".
			$history[] = [
				'role'         => 'tool',
				'tool_call_id' => $tool_call_id,
				'content'      => 'Reclassifying with hint: ' . $hint,
			];

			return [ self::RECLASSIFY_SENTINEL => $hint ];
		}

		// Server-side scope authorization. The tool name comes from the LLM (relayed by
		// the middleware), so reject any call whose scope was not pinned for this turn by
		// the capability-filtered classify round. A synthetic tool error is appended to
		// history so the model is told and can `reclassify`; no framing is emitted because
		// the call never executes. The registry backstop re-checks the capability anyway.
		if ( ! in_array( $tool_slug, $allowed_scopes, true ) ) {
			$history[] = $this->build_scope_rejected_tool_message( $tool_call_id, $tool_slug );

			return null;
		}

		$this->sink_tool_frame( 'tool_call_start', $tool_slug, $tool_call_id, $label );

		$result = $this->registry->resolve_tool_call( $tool_name, (array) ( $call['arguments'] ?? [] ), $this->payload );

		$this->sink_tool_frame( 'tool_call_end', $tool_slug, $tool_call_id );

		$history[] = [
			'role'         => 'tool',
			'tool_call_id' => $tool_call_id,
			'content'      => (string) $result['content'],
		];

		return null;
	}

	/**
	 * Decode each tool call's `arguments` from a JSON string to an array.
	 *
	 * OpenAI's Responses API delivers `function_call.arguments` as a JSON-encoded
	 * string. The middleware forwards that string verbatim, so the value arrives
	 * here as a string. Every downstream consumer — set-hash, label derivation,
	 * dispatch into the registry, history persistence — expects an array. Casting
	 * a string to array yields `[ "{json}" ]`, silently dropping every argument.
	 *
	 * Normalize once at the boundary; preserve already-decoded arrays so tests
	 * that pass tool calls as arrays keep working.
	 *
	 * @since 2.0.0
	 *
	 * @param array $tool_calls Tool calls from the middleware response.
	 *
	 * @return array Tool calls with `arguments` decoded to arrays.
	 */
	private function normalize_tool_call_arguments( array $tool_calls ): array {

		foreach ( $tool_calls as $idx => $call ) {
			$args = $call['arguments'] ?? [];

			if ( is_string( $args ) ) {
				$decoded = json_decode( $args, true );
				$args    = is_array( $decoded ) ? $decoded : [];
			}

			$tool_calls[ $idx ]['arguments'] = (array) $args;
		}

		return $tool_calls;
	}

	/**
	 * Split incoming tool calls into accepted and rejected (overflow) lists.
	 *
	 * @since 2.0.0
	 *
	 * @param array $tool_calls Tool calls returned by the middleware.
	 *
	 * @return array { accepted: array, rejected: array }
	 */
	private function enforce_tool_call_cap( array $tool_calls ): array {

		if ( count( $tool_calls ) <= self::MAX_TOOL_CALLS_PER_ROUND ) {
			return [
				'accepted' => $tool_calls,
				'rejected' => [],
			];
		}

		return [
			'accepted' => array_slice( $tool_calls, 0, self::MAX_TOOL_CALLS_PER_ROUND ),
			'rejected' => array_slice( $tool_calls, self::MAX_TOOL_CALLS_PER_ROUND ),
		];
	}

	/**
	 * Hash the set of tool calls in a round for stuck-loop detection.
	 *
	 * Hashes each call's `(name, args_json)` after stripping `_label`. Set-equality
	 * via sorting ensures `[A,B]` and `[B,A]` collapse to the same hash, so the
	 * dedup catches truly identical re-emissions without false positives when the
	 * model reorders calls.
	 *
	 * @since 2.0.0
	 *
	 * @param array $tool_calls Tool calls accepted this round (post-cap-enforcement).
	 *
	 * @return string Hash.
	 */
	private function build_tool_call_set_hash( array $tool_calls ): string {

		if ( $tool_calls === [] ) {
			return '';
		}

		$signatures = [];

		foreach ( $tool_calls as $call ) {
			$args = (array) ( $call['arguments'] ?? [] );

			// Strip the _label arg — it's UX, not data identity.
			unset( $args['_label'] );

			ksort( $args );

			$signatures[] = (string) $call['name'] . '|' . md5( (string) wp_json_encode( $args ) ); // NOSONAR.
		}

		sort( $signatures );

		return md5( implode( '||', $signatures ) ); // NOSONAR.
	}

	/**
	 * Check whether the model is stuck re-emitting an identical tool-call set.
	 *
	 * @since 2.0.0
	 *
	 * @param string $set_hash        Hash of the current round's tool-call set.
	 * @param array  $seen_set_hashes Hashes of all previously dispatched sets this turn.
	 *
	 * @return bool Whether the model is stuck re-emitting an identical set.
	 */
	private function is_stuck_loop( string $set_hash, array $seen_set_hashes ): bool {

		return $set_hash !== '' && in_array( $set_hash, $seen_set_hashes, true );
	}

	/**
	 * Dispatch the accepted tool calls in order, appending results to history.
	 *
	 * Returns a reclassify sentinel or terminal envelope as soon as a call ends
	 * the loop, otherwise null so the answer round can continue.
	 *
	 * @since 2.0.0
	 *
	 * @param array $accepted       Accepted tool calls (post-cap-enforcement).
	 * @param array $history        Conversation history array (passed by reference).
	 * @param array $allowed_scopes Scope slugs pinned for this turn; calls outside are rejected.
	 *
	 * @return array|null Terminal envelope on early exit, null otherwise.
	 */
	private function dispatch_tool_calls( array $accepted, array &$history, array $allowed_scopes ): ?array {

		foreach ( $accepted as $idx => $call ) {
			$terminal = $this->execute_tool_call( $call, $history, $allowed_scopes );

			if ( $terminal !== null ) {
				// Append placeholder tool results for any remaining calls so the
				// assistant message's tool_call IDs all have matching results.
				$this->backfill_remaining_tool_results( $history, $accepted, $idx + 1 );

				return $terminal;
			}
		}

		return null;
	}

	/**
	 * Backfill placeholder tool results for calls skipped after an early exit.
	 *
	 * OpenAI requires a tool result for every tool_call ID in the assistant
	 * message. When the loop exits early (e.g. reclassify sentinel), any
	 * remaining calls have no result yet — this fills the gap.
	 *
	 * @since 2.0.0
	 *
	 * @param array $history  Conversation history array (passed by reference).
	 * @param array $accepted Full list of accepted tool calls.
	 * @param int   $from     Index of the first unanswered call.
	 */
	private function backfill_remaining_tool_results( array &$history, array $accepted, int $from ): void {

		$remaining = array_slice( $accepted, $from );

		foreach ( $remaining as $call ) {
			$history[] = [
				'role'         => 'tool',
				'tool_call_id' => (string) ( $call['id'] ?? '' ),
				'content'      => 'Skipped — reclassifying.',
			];
		}
	}

	/**
	 * Append synthetic `too_many_calls` tool errors for over-cap rejected calls.
	 *
	 * @since 2.0.0
	 *
	 * @param array $history  Conversation history array (passed by reference).
	 * @param array $rejected Tool calls rejected by the per-round cap.
	 */
	private function append_overflow_messages( array &$history, array $rejected ): void {

		foreach ( $rejected as $call ) {
			$history[] = $this->build_overflow_tool_message( (string) ( $call['id'] ?? '' ) );
		}
	}

	/**
	 * Build the synthetic `too_many_calls` tool error message for a rejected call.
	 *
	 * @since 2.0.0
	 *
	 * @param string $tool_call_id The rejected call's ID.
	 *
	 * @return array History entry shaped `{role: 'tool', tool_call_id, content}`.
	 */
	private function build_overflow_tool_message( string $tool_call_id ): array {

		return [
			'role'         => 'tool',
			'tool_call_id' => $tool_call_id,
			'content'      => (string) wp_json_encode(
				[
					'error' => 'Too many tool calls in one round',
					'code'  => 'too_many_calls',
					'hint'  => sprintf(
						'Maximum %d tool calls per round. Re-emit ignored calls in a subsequent round if still needed.',
						self::MAX_TOOL_CALLS_PER_ROUND
					),
				]
			),
		];
	}

	/**
	 * Build the synthetic `scope_not_allowed` tool error for an out-of-scope call.
	 *
	 * Mirrors the overflow-rejection pattern: a `{role: tool}` history entry whose
	 * content is a tool-error envelope. Tells the model the requested scope was not
	 * pinned for this turn so it can fall back to the `reclassify` tool. The string
	 * is consumed by the model, not the user, so it is intentionally not translated —
	 * matching `build_overflow_tool_message()`.
	 *
	 * @since 2.0.0
	 *
	 * @param string $tool_call_id The rejected call's ID.
	 * @param string $tool_slug    The disallowed scope slug parsed from the tool name.
	 *
	 * @return array History entry shaped `{role: 'tool', tool_call_id, content}`.
	 */
	private function build_scope_rejected_tool_message( string $tool_call_id, string $tool_slug ): array {

		return [
			'role'         => 'tool',
			'tool_call_id' => $tool_call_id,
			'content'      => (string) wp_json_encode(
				[
					'error' => sprintf(
						'The "%s" scope is not available for this query. Use the reclassify tool to request a different scope.',
						$tool_slug
					),
					'code'  => 'scope_not_allowed',
				]
			),
		];
	}

	/**
	 * Choose the spinner-copy label for a `tool_call_start` frame.
	 *
	 * LLM-composed `arguments._label` wins when present — it can reference args
	 * (e.g. "Looking up your forms from the last 3 months…"). Falls back to the
	 * scope's `get_tool_call_default_label()`; finally to a generic localized
	 * string when the scope cannot be resolved.
	 *
	 * @since 2.0.0
	 *
	 * @param array $tool_call The tool call frame from the middleware. Has
	 *                         `name` (scope-prefixed), `arguments`, `id`.
	 *
	 * @return string Label text.
	 */
	private function derive_tool_call_label( array $tool_call ): string {

		$args  = (array) ( $tool_call['arguments'] ?? [] );
		$label = isset( $args['_label'] ) ? trim( (string) $args['_label'] ) : '';

		if ( $label !== '' ) {
			return $label;
		}

		$name = (string) ( $tool_call['name'] ?? '' );

		return $this->resolve_scope_default_label( $name, $args );
	}

	/**
	 * Resolve a scope's default tool-call label from a scope-prefixed tool name.
	 *
	 * Falls back to a generic localized string when the name is not scope-prefixed
	 * or the scope cannot be resolved.
	 *
	 * @since 2.0.0
	 *
	 * @param string $name Scope-prefixed tool name (e.g. `analytics__form_stats`).
	 * @param array  $args Tool-call arguments (the UX-only `_label` is stripped before dispatch).
	 *
	 * @return string Label text.
	 */
	private function resolve_scope_default_label( string $name, array $args ): string {

		$separator_pos = strpos( $name, '__' );
		$working_on_it = __( 'Working on it…', 'wpforms-lite' );

		if ( $separator_pos === false ) {
			return $working_on_it;
		}

		$scope_slug = substr( $name, 0, $separator_pos );
		$scope      = $this->registry->get( $scope_slug );

		if ( $scope === null ) {
			return $working_on_it;
		}

		unset( $args['_label'] );

		$includes = [ substr( $name, $separator_pos + 2 ) ];

		return $scope->get_tool_call_default_label( $includes, $args );
	}

	/**
	 * Extract the scope slug from a tool call's prefixed name.
	 *
	 * @since 2.0.0
	 *
	 * @param string $tool_name Scope-prefixed name (e.g. `analytics__form_stats`).
	 *
	 * @return string Scope slug, or the raw name for synthetic tools (e.g. `reclassify`).
	 */
	private function extract_tool_slug( string $tool_name ): string {

		$separator_pos = strpos( $tool_name, '__' );

		if ( $separator_pos === false ) {
			return $tool_name;
		}

		return substr( $tool_name, 0, $separator_pos );
	}

	/**
	 * Map a tool-call list to diagnostic objects `{ name, args, label }`.
	 *
	 * Keeps the full scope-prefixed tool name (e.g. `analytics__field_stats`) plus the call
	 * arguments. The internal `_label` arg (UX spinner copy, not data) is lifted out to a
	 * top-level `label` and stripped from `args`.
	 *
	 * @since 2.0.0
	 *
	 * @param array $tool_calls Accepted tool calls for a round.
	 *
	 * @return array List of `{ name, args, label? }` objects in execution order.
	 */
	private function collect_tool_calls( array $tool_calls ): array {

		$calls = [];

		foreach ( $tool_calls as $call ) {
			$name = (string) ( $call['name'] ?? '' );

			if ( $name === '' ) {
				continue;
			}

			$args  = (array) ( $call['arguments'] ?? [] );
			$label = isset( $args['_label'] ) ? (string) $args['_label'] : '';

			unset( $args['_label'] );

			$entry = [
				'name' => $name,
				'args' => (object) $args,
			];

			if ( $label !== '' ) {
				$entry['label'] = $label;
			}

			$calls[] = $entry;
		}

		return $calls;
	}

	/**
	 * Build a uniform success response envelope.
	 *
	 * @since 2.0.0
	 *
	 * @param array $data Response payload.
	 *
	 * @return array
	 */
	private function success( array $data ): array {

		return [
			'status' => 'success',
			'code'   => null,
			'error'  => null,
			'data'   => $data,
		];
	}

	/**
	 * Build a uniform error response envelope.
	 *
	 * The `error` field name (not `message`) matches what api.js / chat-helpers-admin consume.
	 *
	 * @since 2.0.0
	 *
	 * @param string $code  Error code slug.
	 * @param string $error Human-readable error message.
	 *
	 * @return array
	 */
	private function error( string $code, string $error ): array {

		return [
			'status' => 'error',
			'code'   => $code,
			'error'  => $error,
			'data'   => null,
		];
	}
}