promptBuilder = $promptBuilder; $this->abilityResolver = $abilityResolver; $this->knowledgeResolver = $knowledgeResolver; $this->clientAdapter = $clientAdapter; $this->confirmationStore = $confirmationStore; $this->abilityCatalog = $abilityCatalog; } /** * Runs an agent against a user message. * * @param AgentConfig $agent The agent configuration. * @param string $message The user message. * @param array> $history Prior turns as ['role' => ..., 'text' => ..., 'attachments' => [...]]. * @param array> $attachments Attachments for the current turn (each ['data' => dataUri, 'mimeType' => ..., 'name' => ...]). * @param string $requestId Optional client-generated UUID for live progress reporting. * @param string[] $autoApprovedAbilities Ability names the user has approved for the * whole session; calls to them skip the * write-confirmation gate. * * @return AgentResponse * @since 1.0.0 */ public function chat(AgentConfig $agent, string $message, array $history = [], array $attachments = [], string $requestId = '', array $autoApprovedAbilities = []): AgentResponse { $guard = $this->guard($agent->provider); if ($guard instanceof WP_Error) { return AgentResponse::fromError($guard); } if ('' === trim($message) && empty($attachments)) { return AgentResponse::fromError( new WP_Error('empty_message', __('The user message cannot be empty.', 'agent-mod')) ); } $siteContext = $agent->autoIncludeSiteContext ? $this->knowledgeResolver->getSiteContext() : []; // Before the tools are selected, and stored with the agent so a paused // run resumes with the same tool list. $agent->preloadedAbilities = $this->recentToolNames($history); $toolset = $this->resolveToolset($agent); $systemInstruction = $this->promptBuilder->buildSystemInstruction($agent, $siteContext, $toolset['catalog']); $messages = $this->mapHistoryToMessages($history); $messages[] = new Message(MessageRoleEnum::user(), $this->buildMessageParts($message, $attachments)); $response = $this->clientAdapter->generate( $systemInstruction, $messages, $toolset['abilities'], $agent->provider, $agent->model, $agent->maxToolCalls, $requestId, $autoApprovedAbilities, $agent->webSearchEnabled, $toolset['tools'] ); if ($response->isPendingConfirmation) { $this->savePendingState($response, $agent, $message, $attachments); } return $response; } /** * Resumes a run that paused for user confirmation. * * Rebuilds the exact wire-format history the run paused with (including the * model message carrying the pending function calls) and hands it to the * adapter, which executes — or declines — those calls directly and lets the * generation loop continue. No synthetic re-prompt is involved, so a * multi-step task carries on with the model's own plan. * * @param AgentConfig $agent The agent configuration of the paused run. * @param array> $wireHistory Serialized Message[] history from the confirmation state. * @param array> $executedCalls Calls already executed in earlier legs of this chain. * @param string $originalMessage Original user message of the paused turn. * @param array> $attachments Attachments of the paused turn. * @param string $requestId Optional client-generated UUID for live progress reporting. * @param string[] $autoApprovedAbilities Session-approved ability names. * @param string $decision 'approve' or 'decline' the pending calls, or * 'answer' the agent's questions. * @param array $answerPayload Function response for the ask-user call ('answer' only). * * @return AgentResponse * @since 1.1.0 */ public function resume( AgentConfig $agent, array $wireHistory, array $executedCalls, string $originalMessage, array $attachments, string $requestId, array $autoApprovedAbilities, string $decision, array $answerPayload = [] ): AgentResponse { $guard = $this->guard($agent->provider); if ($guard instanceof WP_Error) { return AgentResponse::fromError($guard); } try { $messages = array_map( static function (array $message): Message { return Message::fromArray($message); }, $wireHistory ); } catch (\Throwable $e) { return AgentResponse::fromError( new WP_Error( 'corrupt_state', __('The pending confirmation state is invalid.', 'agent-mod'), ['status' => 500] ) ); } $siteContext = $agent->autoIncludeSiteContext ? $this->knowledgeResolver->getSiteContext() : []; $toolset = $this->resolveToolset($agent); $systemInstruction = $this->promptBuilder->buildSystemInstruction($agent, $siteContext, $toolset['catalog']); $response = $this->clientAdapter->resume( $systemInstruction, $messages, $toolset['abilities'], $agent->provider, $agent->model, $agent->maxToolCalls, $requestId, $autoApprovedAbilities, $agent->webSearchEnabled, $executedCalls, $decision, $answerPayload, $toolset['tools'] ); if ($response->isPendingConfirmation) { // The chain keeps referring to what the user originally asked for. $this->savePendingState($response, $agent, $originalMessage, $attachments); } return $response; } /** * Resolves the abilities of a run and the tools sent up front. * * @param AgentConfig $agent The agent configuration. * * @return array{abilities: \WP_Ability[], tools: \WP_Ability[]|null, catalog: array} * catalog is empty when every ability is sent as a tool. * @since 1.3.4 */ private function resolveToolset(AgentConfig $agent): array { $abilities = $this->abilityResolver->resolve($agent); $toolset = $this->abilityResolver->selectTools($agent, $abilities); $toolset['catalog'] = null === $toolset['tools'] ? [] : $this->abilityCatalog->categoryIndex($abilities); return $toolset; } /** * Ability names the agent used in the recent turns of the conversation. * * Covers the same turns whose tool digests are replayed to the model * (agent_mod_history_tool_digest_turns), so the tools behind the results the * model reads are loaded too. The history comes from the client; the names * are only used after being intersected with the allowed abilities. * * @param array> $history Prior turns. * * @return string[] * @since 1.3.4 */ private function recentToolNames(array $history): array { /** This filter is documented in AIOrchestratorService::mapHistoryToMessages(). */ $turns = max(0, (int) apply_filters('agent_mod_history_tool_digest_turns', 6)); $toolTurns = array_filter($history, static function ($turn): bool { return is_array($turn) && ! empty($turn['toolCalls']) && is_array($turn['toolCalls']); }); $names = []; foreach ($turns > 0 ? array_slice($toolTurns, -$turns) : [] as $turn) { foreach ($turn['toolCalls'] as $call) { $name = is_array($call) ? sanitize_text_field((string) ($call['name'] ?? '')) : ''; if ('' !== $name && AbilityCatalog::FIND !== $name) { $names[] = $name; } } } return array_slice(array_values(array_unique($names)), 0, 20); } /** * Persists the state a paused run needs to resume after user confirmation. * * The full wire-format history — including the model message whose function * calls await approval — is serialized into the transient, so the confirm * endpoint can execute exactly the calls the user saw and continue the run. * Note the payload can carry base64 attachment data; external object caches * (e.g. memcached) cap values around 1MB, matching what the plain history * already stored before. * * @param AgentResponse $response The pending-confirmation response. * @param AgentConfig $agent The agent configuration. * @param string $message Original user message of the paused turn. * @param array> $attachments Attachments of the paused turn. * * @return void * @since 1.1.0 */ private function savePendingState(AgentResponse $response, AgentConfig $agent, string $message, array $attachments): void { $this->confirmationStore->save( $response->confirmationToken, [ 'agent' => $agent, 'message' => $message, 'attachments' => $attachments, 'wireHistory' => array_map( static function (Message $message): array { return $message->toArray(); }, $response->messages ), 'pendingCalls' => $response->pendingToolCalls, // Complete across the whole chain: the generation loop is seeded // with the earlier legs' calls. 'executedCalls' => $response->toolCalls, // 'questions' states are only accepted by the answer endpoint, and // the answers are validated against exactly these questions. 'kind' => $response->pendingKind, 'questions' => $response->pendingQuestions, ], // A person thinking over questions needs longer than a yes/no. 'questions' === $response->pendingKind ? HOUR_IN_SECONDS : 0 ); } /** * Ensures AI text generation is available for the request. * * Provider-agnostic: instead of probing a single hard-coded connector, this * asks the WordPress AI Client whether text generation is supported. When a * specific provider is requested it scopes the check to that provider; when * the provider is empty ("auto") it passes if any configured provider * (Google, OpenAI, Anthropic, …) can handle text generation. * * @param string $provider Provider id, or '' for auto-select. * * @return WP_Error|null Null when text generation is available. * @since 1.0.0 */ private function guard(string $provider): ?WP_Error { if (function_exists('wp_supports_ai') && ! wp_supports_ai()) { return new WP_Error( 'ai_not_supported', __('AI features are not supported in this environment.', 'agent-mod'), ['status' => 503] ); } if (! function_exists('wp_ai_client_prompt')) { return new WP_Error( 'ai_client_unavailable', __('The WordPress AI Client is not available.', 'agent-mod'), ['status' => 503] ); } if (! $this->supportsTextGeneration($provider)) { $message = '' === $provider ? __('No configured AI provider can handle this request. Connect a provider (e.g. Google, OpenAI, or Anthropic) in the AI settings.', 'agent-mod') /* translators: %s: provider id. */ : sprintf(__('The "%s" AI provider is not configured for text generation.', 'agent-mod'), $provider); return new WP_Error('ai_not_configured', $message, ['status' => 503]); } return null; } /** * Checks whether text generation is supported for the given provider. * * Delegates the capability check to the AI Client builder, which considers * the configured connectors and their credentials. An empty provider lets * the client evaluate every configured provider. * * @param string $provider Provider id, or '' for auto-select. * * @return bool True when text generation is available. * @since 1.0.0 */ private function supportsTextGeneration(string $provider): bool { try { $builder = wp_ai_client_prompt('ping'); if ('' !== $provider) { $builder->using_provider($provider); } return (bool) $builder->is_supported_for_text_generation(); } catch (\Throwable $e) { return false; } } /** * Maps a plain history array to AI Client Message objects. * * Two compaction rules keep prior turns cheap without losing the * information the model actually needs: * * - Assistant turns that ran tools get a plain-text `[Tools used this * turn]` digest of each call and its (truncated) result appended, so on * the next user message the model remembers what it already fetched and * does not re-call the same read tools. Only the most recent turns are * replayed this way (agent_mod_history_tool_digest_turns) so the digests * themselves cannot re-grow the prompt. Answers to ask-user and * request-approval are the exception: they are replayed on every turn. * - Binary attachment payloads are only needed on the CURRENT user message * (appended by the caller); prior turns get a text placeholder naming the * files instead of re-uploading base64 data every request. * * @param array> $history Prior turns. * * @return Message[] * @since 1.0.0 */ private function mapHistoryToMessages(array $history): array { /** * Filters how many of the most recent tool-running assistant turns get * their tool-result digests replayed into the provider history. * * @param int $turns Number of assistant turns. * * @since 1.1.5 */ $digestTurns = max(0, (int) apply_filters('agent_mod_history_tool_digest_turns', 6)); $digestIndexes = []; foreach ($history as $index => $turn) { $role = (string) ($turn['role'] ?? 'user'); if (in_array($role, ['model', 'assistant'], true) && ! empty($turn['toolCalls']) && is_array($turn['toolCalls'])) { $digestIndexes[] = $index; } } // array_slice with -0 would return the whole array, so 0 disables replay explicitly. $digestIndexes = $digestTurns > 0 ? array_flip(array_slice($digestIndexes, -$digestTurns)) : []; $messages = []; foreach ($history as $index => $turn) { $text = isset($turn['text']) ? (string) $turn['text'] : ''; $attachments = isset($turn['attachments']) && is_array($turn['attachments']) ? $turn['attachments'] : []; $toolCalls = isset($turn['toolCalls']) && is_array($turn['toolCalls']) ? $turn['toolCalls'] : []; // Older turns still replay what the user answered to ask-user / // request-approval: that is the user's own input, not tool output, // and the conversation may keep referring to it. if (! isset($digestIndexes[$index])) { $toolCalls = array_filter( $toolCalls, static function ($call): bool { return is_array($call) && in_array($call['name'] ?? '', AskUserAbilityService::PAUSING_TOOLS, true); } ); } $digest = $this->buildToolDigestBlock($toolCalls); if ('' !== $digest) { $text = trim($text . "\n\n" . $digest); } $placeholder = $this->attachmentPlaceholder($attachments); if ('' !== $placeholder) { $text = trim($text . "\n\n" . $placeholder); } if ('' === trim($text)) { continue; } $role = isset($turn['role']) ? (string) $turn['role'] : 'user'; $isUser = ! in_array($role, ['model', 'assistant'], true); $messages[] = new Message( $isUser ? MessageRoleEnum::user() : MessageRoleEnum::model(), [new MessagePart(trim($text))] ); } return $messages; } /** * Builds the plain-text digest block for an assistant turn's tool calls. * * @param array> $toolCalls Persisted tool calls (name/args and, when * available, a truncated result digest). * * @return string Empty string when there is nothing to report. * @since 1.1.5 */ private function buildToolDigestBlock(array $toolCalls): string { $lines = []; foreach ($toolCalls as $call) { if (! is_array($call)) { continue; } $name = (string) ($call['name'] ?? ''); if ('' === $name) { continue; } $line = '- ' . $name . '(' . wp_json_encode(is_array($call['args'] ?? null) ? $call['args'] : [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . ')'; $result = (string) ($call['result'] ?? ''); if ('' !== $result) { $line .= ' → ' . $result; } $lines[] = $line; } if (empty($lines)) { return ''; } return "[Tools used this turn]\n" . implode("\n", $lines); } /** * Builds a text placeholder naming a prior turn's attachments. * * @param array> $attachments Attachment records. * * @return string Empty string when the turn has no attachments. * @since 1.1.5 */ private function attachmentPlaceholder(array $attachments): string { $names = []; foreach ($attachments as $attachment) { if (! is_array($attachment)) { continue; } $name = (string) ($attachment['name'] ?? ''); $mime = (string) ($attachment['mimeType'] ?? ''); if ('' === $name && '' === $mime) { continue; } $names[] = trim($name . ('' !== $mime ? ' (' . $mime . ')' : '')); } if (empty($names)) { return ''; } return sprintf( /* translators: %s: comma-separated list of file names. */ __('[Attachments sent with this earlier message (binary data omitted): %s]', 'agent-mod'), implode(', ', $names) ); } /** * Builds the message parts for a turn: a text part plus one file part per * attachment. Invalid attachments are skipped so a malformed upload never * breaks the whole request. * * @param string $text The turn text. * @param array> $attachments Attachments (each ['data' => dataUri, 'mimeType' => ..., 'name' => ...]). * * @return MessagePart[] * @since 1.0.0 */ private function buildMessageParts(string $text, array $attachments): array { $parts = []; $text = trim($text); if ('' !== $text) { $parts[] = new MessagePart($text); } foreach ($attachments as $attachment) { if (! is_array($attachment)) { continue; } $file = $this->attachmentToFile($attachment); if ($file instanceof File) { $parts[] = new MessagePart($file); } } // A message must carry at least one part; fall back to an (empty) text part. if (empty($parts)) { $parts[] = new MessagePart($text); } return $parts; } /** * Converts a sanitized attachment array to a WP AI Client File, or null when * the data cannot be turned into a valid file. * * @param array $attachment Attachment data (['data' => dataUri, 'mimeType' => ...]). * * @return File|null * @since 1.0.0 */ private function attachmentToFile(array $attachment): ?File { $data = isset($attachment['data']) ? (string) $attachment['data'] : ''; if ('' === $data) { return null; } $mimeType = isset($attachment['mimeType']) && '' !== $attachment['mimeType'] ? (string) $attachment['mimeType'] : null; try { return new File($data, $mimeType); } catch (\Throwable $e) { return null; } } }