orchestrator = $orchestrator; $this->confirmationStore = $confirmationStore; $this->providerInfo = $providerInfo; $this->progressStore = $progressStore; $this->settings_service = $settingsService; $this->askUser = $askUser; add_action('rest_api_init', [$this, 'registerRoutes']); } /** * Registers the REST routes. * * @return void * @since 1.0.0 */ public function registerRoutes(): void { $chatArgs = [ 'methods' => 'POST', 'callback' => [$this, 'handleChat'], 'permission_callback' => [$this, 'checkPermission'], ]; // Temporary testing route (kept for backwards compatibility). register_rest_route(Constants::REST_NAMESPACE, '/test-chat', $chatArgs); // Permanent chat route used by the admin chat widget. register_rest_route(Constants::REST_NAMESPACE, '/chat', $chatArgs); // Returns the list of published agents for the agent selector. register_rest_route( Constants::REST_NAMESPACE, '/agents', [ 'methods' => 'GET', 'callback' => [$this, 'handleAgents'], 'permission_callback' => [$this, 'checkPermission'], ] ); // Returns the list of connected AI providers. register_rest_route( Constants::REST_NAMESPACE, '/providers', [ 'methods' => 'GET', 'callback' => [$this, 'handleProviders'], 'permission_callback' => [$this, 'checkPermission'], ] ); // Returns the text-generation models for a connected provider. register_rest_route( Constants::REST_NAMESPACE, '/provider-models', [ 'methods' => 'GET', 'callback' => [$this, 'handleProviderModels'], 'permission_callback' => [$this, 'checkPermission'], 'args' => [ 'provider' => [ 'type' => 'string', 'required' => true, ], ], ] ); // Resumes a pending write operation after user confirmation. register_rest_route( Constants::REST_NAMESPACE, '/confirm-action', [ 'methods' => 'POST', 'callback' => [$this, 'handleConfirmAction'], 'permission_callback' => [$this, 'checkPermission'], ] ); // Resumes a run that paused on questions the agent asked the user. register_rest_route( Constants::REST_NAMESPACE, '/answer-questions', [ 'methods' => 'POST', 'callback' => [$this, 'handleAnswerQuestions'], 'permission_callback' => [$this, 'checkPermission'], ] ); // Requests cancellation of an in-flight chat request. The orchestration // loop checks the flag between iterations and bails out early. register_rest_route( Constants::REST_NAMESPACE, '/chat-stop', [ 'methods' => 'POST', 'callback' => [$this, 'handleChatStop'], 'permission_callback' => [$this, 'checkPermission'], 'args' => [ 'requestId' => [ 'type' => 'string', 'required' => true, ], ], ] ); // Live tool-call progress for an in-flight chat request (polled by the widget). register_rest_route( Constants::REST_NAMESPACE, '/chat-progress', [ 'methods' => 'GET', 'callback' => [$this, 'handleChatProgress'], 'permission_callback' => [$this, 'checkPermission'], 'args' => [ 'requestId' => [ 'type' => 'string', 'required' => true, ], ], ] ); } /** * Permission check for the endpoint. * * @return bool * @since 1.0.0 */ public function checkPermission(): bool { return current_user_can('manage_options'); } /** * Handles a chat request. * * @param WP_REST_Request $request The REST request. * * @return WP_REST_Response|WP_Error * @since 1.0.0 */ public function handleChat(WP_REST_Request $request) { $message = sanitize_textarea_field((string) $request->get_param('message')); $attachments = $this->sanitizeAttachments($request->get_param('attachments')); if ('' === trim($message) && empty($attachments)) { return new WP_Error( 'missing_message', __('A "message" or an attachment is required.', 'agent-mod'), ['status' => 400] ); } $agentData = $request->get_param('agent'); $agentData = is_array($agentData) ? Helper::sanitizeArray($agentData) : []; /** * Filters the agent configuration data before the AgentConfig DTO is built. * * Lets extensions (e.g. Pro) resolve a full agent configuration * server-side from the submitted agent id. Callbacks must preserve the * request-level keys: id, mode, provider, model and emphasizedAbilities. * * @param array $agentData Sanitized agent data from the request. * @since 1.0.5 */ $agentData = (array) apply_filters('agent_mod_agent_config_data', $agentData); $agent = AgentConfig::fromArray($agentData); // The admin chat has a person at the keyboard: offer the ask-user tool. // Decided here, never from request data; resumed runs carry it in the // stored AgentConfig. $agent->interactive = true; $autoApproved = $this->sanitizeAutoApprovedAbilities($request->get_param('autoApprovedAbilities')); $agent->approvalMode = $this->resolveApprovalMode($autoApproved); $conversationId = (int) $request->get_param('conversationId'); // Load history from DB when a conversationId is provided (pro); otherwise // use the client-supplied history for the first turn of a new conversation. if ($conversationId > 0) { $history = (array) apply_filters('agent_mod_load_conversation_history', [], $conversationId); } else { $history = $request->get_param('history'); $history = is_array($history) ? $this->sanitizeHistory($history) : []; } do_action('agent_mod_before_chat', $agent, $message, $history); $response = $this->orchestrator->chat( $agent, $message, $history, $attachments, $this->sanitizeRequestId($request), $autoApproved ); do_action('agent_mod_after_chat', $agent, $response, $conversationId); if ($response->isError()) { $error = $response->error; $data = $error->get_error_data(); $status = is_array($data) && isset($data['status']) ? (int) $data['status'] : 500; return new WP_REST_Response($response->toArray(), $status); } // Persist the new turns when the response is a normal answer. // Pending-confirmation responses are not persisted yet — only saved to // the transient store by AIOrchestratorService until confirmed. The // conversation itself is created lazily here so failed or abandoned // requests never leave empty conversation posts behind. if (! $response->isPendingConfirmation) { if ($conversationId <= 0) { /** * Filters the conversation ID for a new chat session. * * Lets extensions (e.g. Pro) persist a new conversation and return * its ID. Returning 0 keeps the conversation ephemeral. * * @param int $conversationId Default 0 (no persistence). * @param int $agentId Stored agent post ID, 0 for the default agent. * @param string $source Origin of the conversation. * @param string $message First user message, e.g. for deriving a title. * * @since 1.0.5 */ $conversationId = (int) apply_filters('agent_mod_create_conversation', 0, (int) ($agentData['id'] ?? 0), 'admin_chat', $message); } if ($conversationId > 0) { $newTurns = [ ['role' => 'user', 'text' => $message, 'attachments' => $attachments], [ 'role' => 'assistant', 'text' => $response->text, 'attachments' => [], 'toolCalls' => $response->toolCalls, 'tokenUsage' => $response->tokenUsage, // The resolved provider/model that produced this answer, // so a restored conversation can continue on the same // model instead of silently falling back to a default. 'provider' => (string) $agent->provider, 'model' => (string) ($agent->model ?? ''), ], ]; /** * Fires after a chat turn completes, with the new turns to persist. * * Assistant turns carry `text`, `attachments`, `toolCalls`, * `tokenUsage` and (since 1.3.6) `provider` / `model` — the * provider/model pair the answer was generated with. * * @param int $conversationId Conversation ID. * @param array $newTurns The user turn and the assistant turn. * * @since 1.0.5 */ do_action('agent_mod_append_messages', $conversationId, $newTurns); } } $payload = $response->toArray(); $payload['conversationId'] = $conversationId ?: null; return rest_ensure_response($payload); } /** * Returns the list of published agents. * * @return WP_REST_Response * @since 1.0.0 */ public function handleAgents(): WP_REST_Response { return rest_ensure_response((array) apply_filters('agent_mod_get_agents', [])); } /** * Returns the list of connected AI providers. * * @return WP_REST_Response * @since 1.0.0 */ public function handleProviders(): WP_REST_Response { return rest_ensure_response($this->providerInfo->getConnectedProviders()); } /** * Returns the text-generation models for a connected provider. * * @param WP_REST_Request $request The REST request. * * @return WP_REST_Response * @since 1.0.0 */ public function handleProviderModels(WP_REST_Request $request): WP_REST_Response { $provider = sanitize_key((string) $request->get_param('provider')); return rest_ensure_response($this->providerInfo->getTextModels($provider)); } /** * Resumes a write operation after the user has confirmed or declined it. * * True continuation, not a re-prompt: the stored wire-format history ends * with the model message whose function calls the user saw in the dialog. * On approval the orchestrator executes exactly those calls and feeds their * real responses back, so the model carries on with its own multi-step plan * (and pauses again — new token — for any further un-approved write). On * decline the calls receive a synthetic "declined" response so the model can * acknowledge gracefully. * * @param WP_REST_Request $request The REST request. * * @return WP_REST_Response|WP_Error * @since 1.0.0 */ public function handleConfirmAction(WP_REST_Request $request) { $decision = sanitize_key((string) $request->get_param('decision')); $decision = in_array($decision, ['approve', 'decline'], true) ? $decision : 'approve'; $state = $this->consumePendingState($request, 'confirmation'); if ($state instanceof WP_Error) { return $state; } if (empty($this->normalizeToolCalls($state['pendingCalls'] ?? []))) { return new WP_Error('corrupt_state', __('The pending confirmation state is invalid.', 'agent-mod'), ['status' => 500]); } return $this->resumePendingRun($request, $state, $decision); } /** * Resumes a run that paused on questions the agent asked the user. * * The answers become the ask-user call's function response, validated * against the questions stored with the paused state (not against what the * client claims was asked). "skipped" tells the model to continue with * defaults instead. * * @param WP_REST_Request $request The REST request. * * @return WP_REST_Response|WP_Error * @since 1.3.1 */ public function handleAnswerQuestions(WP_REST_Request $request) { $state = $this->consumePendingState($request, 'questions'); if ($state instanceof WP_Error) { return $state; } $questions = is_array($state['questions'] ?? null) ? $state['questions'] : []; if (empty($questions['questions'])) { return new WP_Error('corrupt_state', __('The pending questions are invalid.', 'agent-mod'), ['status' => 500]); } $payload = $this->askUser->buildAnswerPayload( $questions, $request->get_param('answers'), rest_sanitize_boolean($request->get_param('skipped')) ); return $this->resumePendingRun($request, $state, 'answer', $payload); } /** * Loads and consumes a paused run's state of the expected kind. * * The kind is checked before consuming, so a token sent to the wrong * endpoint stays usable for the right one. States saved before kinds * existed count as confirmations. * * @param WP_REST_Request $request The REST request. * @param string $kind 'confirmation' or 'questions'. * * @return array|WP_Error * @since 1.3.1 */ private function consumePendingState(WP_REST_Request $request, string $kind) { $token = sanitize_text_field((string) $request->get_param('token')); if ('' === $token) { return new WP_Error('missing_token', __('A confirmation token is required.', 'agent-mod'), ['status' => 400]); } $state = $this->confirmationStore->load($token); if (null === $state) { return new WP_Error('invalid_token', __('The confirmation token is invalid or has expired.', 'agent-mod'), ['status' => 404]); } $stateKind = (string) ($state['kind'] ?? '') ?: 'confirmation'; if ($kind !== $stateKind) { return new WP_Error('wrong_token_kind', __('This token belongs to a different kind of pending request.', 'agent-mod'), ['status' => 400]); } $this->confirmationStore->consume($token); return $state; } /** * Resumes a paused run and persists the completed turn. * * Shared by the confirmation and the answer endpoints. The resumed run may * pause again (another write to confirm, more questions), in which case * nothing is persisted yet and the new pending payload goes to the client. * * @param WP_REST_Request $request The REST request. * @param array $state The consumed pending state. * @param string $decision 'approve', 'decline' or 'answer'. * @param array $answerPayload Ask-user function response ('answer' only). * * @return WP_REST_Response|WP_Error * @since 1.3.1 */ private function resumePendingRun(WP_REST_Request $request, array $state, string $decision, array $answerPayload = []) { $conversationId = (int) $request->get_param('conversationId'); $agent = $state['agent'] ?? null; $wireHistory = is_array($state['wireHistory'] ?? null) ? $state['wireHistory'] : []; $message = (string) ($state['message'] ?? ''); $attachments = is_array($state['attachments'] ?? null) ? $state['attachments'] : []; $executedCalls = $this->normalizeToolCalls($state['executedCalls'] ?? []); if (! $agent instanceof AgentConfig || empty($wireHistory)) { return new WP_Error('corrupt_state', __('The pending confirmation state is invalid.', 'agent-mod'), ['status' => 500]); } // The user may have switched Manual/Auto while the run was paused. $autoApproved = $this->sanitizeAutoApprovedAbilities($request->get_param('autoApprovedAbilities')); $agent->approvalMode = $this->resolveApprovalMode($autoApproved); $response = $this->orchestrator->resume( $agent, $wireHistory, $executedCalls, $message, $attachments, $this->sanitizeRequestId($request), $autoApproved, $decision, $answerPayload ); if ($response->isError()) { $error = $response->error; $data = $error->get_error_data(); $status = is_array($data) && isset($data['status']) ? (int) $data['status'] : 500; return new WP_REST_Response($response->toArray(), $status); } if (! $response->isPendingConfirmation) { // The confirmation may belong to the very first turn of a session, in // which case no conversation exists yet — create it lazily, exactly // like handleChat() does. This filter is documented in handleChat(). if ($conversationId <= 0) { $conversationId = (int) apply_filters('agent_mod_create_conversation', 0, (int) ($agent->id ?? 0), 'admin_chat', $message); } if ($conversationId > 0) { $newTurns = [ ['role' => 'user', 'text' => $message, 'attachments' => $attachments], [ 'role' => 'assistant', 'text' => $response->text, 'attachments' => [], // Seeded loop: already the complete chain-wide list. 'toolCalls' => $response->toolCalls, 'tokenUsage' => $response->tokenUsage, 'provider' => (string) $agent->provider, 'model' => (string) ($agent->model ?? ''), ], ]; // This action is documented in handleChat(). do_action('agent_mod_append_messages', $conversationId, $newTurns); } } $payload = $response->toArray(); $payload['conversationId'] = $conversationId ?: null; return rest_ensure_response($payload); } /** * Sanitizes the session-approved ability names sent by the client. * * Accepts registered-ability-shaped names (vendor/ability) plus the literal * '*' wildcard, capped at 50 unique entries. The result runs through the * agent_mod_auto_approved_abilities filter so site owners and extensions can * strip abilities (e.g. destructive ones) from session approval entirely. * * @param mixed $raw Raw request value. * * @return string[] * @since 1.1.0 */ private function sanitizeAutoApprovedAbilities($raw): array { if (! is_array($raw)) { return []; } $names = []; foreach ($raw as $name) { if (! is_string($name)) { continue; } $name = trim(sanitize_text_field($name)); if ('*' !== $name && ! preg_match('#^[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9_-]*$#', $name)) { continue; } $names[] = $name; if (count($names) >= 50) { break; } } $names = array_values(array_unique($names)); /** * Filters the ability names the user has session-approved. * * Runs after sanitization on every chat and confirm request. Returning a * reduced list revokes session approval for the removed abilities — they * will prompt again despite the user's "don't ask again" choice. * * @param string[] $names Sanitized ability names ('*' is the blanket wildcard). * * @since 1.1.0 */ return array_values(array_filter( (array) apply_filters('agent_mod_auto_approved_abilities', $names), 'is_string' )); } /** * Derives the chat's approval mode from the session allowlist. * * The Auto picker sends the '*' wildcard; anything else (including a list * of per-ability "don't ask again" names) is still Manual. Runs on the * filtered list, so a site owner who revokes '*' also gets Manual behavior. * * @param string[] $autoApproved Sanitized session-approved ability names. * * @return string 'manual' or 'auto'. * @since 1.3.2 */ private function resolveApprovalMode(array $autoApproved): string { return in_array('*', $autoApproved, true) ? 'auto' : 'manual'; } /** * Normalizes a stored tool-call list to ['name' => string, 'args' => array] entries. * * Anything without a name is dropped: the confirmation state comes from a * transient written by an earlier request, so it is treated as untrusted shape. * * @param mixed $raw Raw tool-call list. * * @return array> * @since 1.1.0 */ private function normalizeToolCalls($raw): array { if (! is_array($raw)) { return []; } $calls = []; foreach ($raw as $call) { if (! is_array($call)) { continue; } $name = isset($call['name']) ? (string) $call['name'] : ''; if ('' === $name) { continue; } $calls[] = [ 'name' => $name, 'args' => is_array($call['args'] ?? null) ? $call['args'] : [], ]; } return $calls; } /** * Flags an in-flight chat request as stop-requested. * * The frontend additionally aborts its own fetch; this flag makes the * server-side tool-calling loop bail out at the next iteration boundary so * no further provider calls or ability executions happen for the request. * * @param WP_REST_Request $request The REST request. * * @return WP_REST_Response|WP_Error * @since 1.0.5 */ public function handleChatStop(WP_REST_Request $request) { $requestId = $this->sanitizeRequestId($request); if ('' === $requestId) { return new WP_Error('invalid_request_id', __('Invalid request id.', 'agent-mod'), ['status' => 400]); } $this->progressStore->requestStop($requestId); return rest_ensure_response(['success' => true]); } /** * Returns the live tool-call progress for an in-flight chat request. * * @param WP_REST_Request $request The REST request. * * @return WP_REST_Response|WP_Error * @since 1.1.0 */ public function handleChatProgress(WP_REST_Request $request) { $requestId = $this->sanitizeRequestId($request); if ('' === $requestId) { return new WP_Error('invalid_request_id', __('Invalid request id.', 'agent-mod'), ['status' => 400]); } $state = $this->progressStore->load($requestId); $response = rest_ensure_response($state ?? ['status' => 'unknown']); $response->header('Cache-Control', 'no-store'); return $response; } /** * Extracts and validates the client-generated request id (UUID) from a request. * * @param WP_REST_Request $request The REST request. * * @return string The UUID, or '' when missing/invalid. * @since 1.1.0 */ private function sanitizeRequestId(WP_REST_Request $request): string { $requestId = sanitize_text_field((string) $request->get_param('requestId')); return wp_is_uuid($requestId) ? $requestId : ''; } /** * Sanitizes the history payload into ['role' => ..., 'text' => ...] turns. * * @param array $history Raw history. * * @return array> * @since 1.0.0 */ private function sanitizeHistory(array $history): array { $turns = []; foreach ($history as $turn) { if (! is_array($turn)) { continue; } $text = isset($turn['text']) ? sanitize_textarea_field((string) $turn['text']) : ''; $attachments = $this->sanitizeAttachments($turn['attachments'] ?? null); if ('' === trim($text) && empty($attachments)) { continue; } $sanitizedTurn = [ 'role' => sanitize_key($turn['role'] ?? 'user'), 'text' => $text, 'attachments' => $attachments, ]; /** * Allows Pro to merge extra sanitized fields (e.g. structured_data) into * a history turn before it enters the orchestration pipeline. * * @param array $sanitizedTurn Sanitized turn with role/text/attachments. * @param array $turn Raw turn from the request payload. * * @since 1.0.0 */ $turns[] = apply_filters('agent_mod_sanitize_history_turn', $sanitizedTurn, $turn); } return $turns; } /** * Sanitizes the attachments payload. * * Each attachment must be a base64 data URI whose MIME type is on the * allow-list and whose decoded size is within the per-file limit. Anything * malformed, oversized, or of a disallowed type is dropped, and the number of * attachments is capped. The returned data URI is rebuilt from the validated * MIME type and base64 payload so nothing untrusted is forwarded verbatim. * * @param mixed $raw Raw attachments value from the request. * * @return array> * @since 1.0.0 */ private function sanitizeAttachments($raw): array { if (! is_array($raw)) { return []; } $clean = []; foreach ($raw as $item) { if (count($clean) >= $this->settings_service->getAttachmentMaxCount()) { break; } if (! is_array($item)) { continue; } $parsed = $this->parseDataUri(isset($item['data']) ? (string) $item['data'] : ''); if (null === $parsed) { continue; } [$mimeType, $base64] = $parsed; if (! in_array($mimeType, $this->settings_service->getAttachmentMimeTypes(), true)) { continue; } // Approximate the decoded size from the base64 length (4 chars -> 3 bytes). $decodedSize = (int) (strlen($base64) * 3 / 4); if ($decodedSize >$this->settings_service->getAttachmentMaxBytes()) { continue; } $clean[] = [ 'name' => isset($item['name']) ? sanitize_file_name((string) $item['name']) : '', 'mimeType' => $mimeType, 'data' => 'data:' . $mimeType . ';base64,' . $base64, ]; } return $clean; } /** * Parses and validates a base64 data URI. * * @param string $data The candidate data URI. * * @return array{0: string, 1: string}|null [mimeType, base64Data] or null when invalid. * @since 1.0.0 */ private function parseDataUri(string $data): ?array { if ('' === $data) { return null; } $pattern = '#^data:([a-z0-9][a-z0-9!\#$&\-\^_+./]*);base64,([A-Za-z0-9+/]+={0,2})$#i'; if (! preg_match($pattern, $data, $matches)) { return null; } $base64 = $matches[2]; // Reject payloads that are not valid base64. if (false === base64_decode($base64, true)) { return null; } return [strtolower($matches[1]), $base64]; } }