getHttpTransporter(); $config = $this->getConfig(); $customOptions = $config->getCustomOptions(); $pollTimeout = isset($customOptions['poll_timeout']) ? (int) $customOptions['poll_timeout'] : SettingsStore::pollTimeout(); $pollInterval = isset($customOptions['poll_interval']) ? (int) $customOptions['poll_interval'] : SettingsStore::pollInterval(); $archiveAfter = array_key_exists('archive_after_run', $customOptions) ? (bool) $customOptions['archive_after_run'] : SettingsStore::archiveAfterRun(); $pollTimeout = max(30, min(600, $pollTimeout)); $pollInterval = max(1, min(30, $pollInterval)); $params = $this->prepareCreateAgentParams($prompt); $options = $this->buildRequestOptions($pollTimeout); $createRequest = new Request( HttpMethodEnum::POST(), CursorProvider::url('v1/agents'), array('Content-Type' => 'application/json', 'Accept' => 'application/json'), $params, $options ); $createRequest = $this->getRequestAuthentication()->authenticateRequest($createRequest); $createResponse = $httpTransporter->send($createRequest); ResponseUtil::throwIfNotSuccessful($createResponse); /** @var CreateAgentResponse $createData */ $createData = $createResponse->getData(); $agentId = isset($createData['agent']['id']) ? (string) $createData['agent']['id'] : ''; $runId = isset($createData['run']['id']) ? (string) $createData['run']['id'] : ''; if ($agentId === '' || $runId === '') { throw new RuntimeException('Cursor create-agent response did not include agent.id and run.id.'); } $runData = isset($createData['run']) && is_array($createData['run']) ? $createData['run'] : array(); $status = isset($runData['status']) ? strtoupper((string) $runData['status']) : ''; // Create may report FINISHED without a `result` body. Always poll/fetch the run // until we have terminal status with result text (or a non-FINISHED terminal state). $needsPoll = ( !in_array($status, self::TERMINAL_STATUSES, true) || ($status === 'FINISHED' && $this->runResultText($runData) === '') ); if ($needsPoll) { $runData = $this->pollRunUntilTerminal($agentId, $runId, $pollTimeout, $pollInterval, $options); $status = isset($runData['status']) ? strtoupper((string) $runData['status']) : ''; } if ($status !== 'FINISHED') { $resultHint = isset($runData['result']) ? (string) $runData['result'] : ''; throw new RuntimeException( sprintf( 'Cursor Cloud Agent run ended with status "%s"%s.', esc_html($status !== '' ? sanitize_text_field($status) : 'unknown'), $resultHint !== '' ? ': ' . esc_html(sanitize_text_field($resultHint)) : '' ) ); } $resultText = $this->runResultText($runData); if ($archiveAfter) { $this->archiveAgentBestEffort($agentId, $options); } $candidate = new Candidate( new Message(MessageRoleEnum::model(), array(new MessagePart($resultText))), FinishReasonEnum::stop() ); return new GenerativeAiResult( $runId, array($candidate), new TokenUsage(0, 0, 0), $this->providerMetadata(), $this->metadata(), array( 'agentId' => $agentId, 'runId' => $runId, 'durationMs' => isset($runData['durationMs']) ? (int) $runData['durationMs'] : null, ) ); } /** * Builds create-agent request body. * * @since 0.1.0 * * @param list $prompt Prompt messages. * @return array */ protected function prepareCreateAgentParams(array $prompt): array { $config = $this->getConfig(); $text = $this->flattenPromptToText($prompt, $config->getSystemInstruction()); if ($text === '') { throw new InvalidArgumentException('Prompt text must not be empty for Cursor Cloud Agents.'); } $params = array( 'prompt' => array( 'text' => $text, ), ); $modelId = $this->metadata()->getId(); if ($modelId !== '' && !in_array(strtolower($modelId), array('auto', 'default'), true)) { $params['model'] = array( 'id' => $modelId, ); } $customOptions = $config->getCustomOptions(); foreach ($customOptions as $key => $value) { if (in_array($key, array('poll_timeout', 'poll_interval', 'archive_after_run'), true)) { continue; } if (isset($params[$key])) { throw new InvalidArgumentException( sprintf( 'The custom option "%s" conflicts with an existing parameter.', sanitize_key((string) $key) ) ); } $params[$key] = $value; } return $params; } /** * Flattens chat messages into a single prompt string. * * @since 0.1.0 * * @param list $messages Messages. * @param string|null $systemInstruction Optional system instruction from model config. */ protected function flattenPromptToText(array $messages, ?string $systemInstruction): string { $chunks = array(); if (is_string($systemInstruction) && $systemInstruction !== '') { $chunks[] = "System:\n" . $systemInstruction; } foreach ($messages as $message) { $role = $message->getRole()->isModel() ? 'Assistant' : 'User'; $textParts = array(); foreach ($message->getParts() as $part) { $text = $part->getText(); if (is_string($text) && $text !== '') { $textParts[] = $text; } } if ($textParts === array()) { continue; } $chunks[] = $role . ":\n" . implode("\n", $textParts); } return trim(implode("\n\n", $chunks)); } /** * Polls a run until it reaches a terminal status or times out. * * @since 0.1.0 * * @param string $agentId Agent ID. * @param string $runId Run ID. * @param int $pollTimeout Max wait seconds. * @param int $pollInterval Sleep between polls. * @param RequestOptions $options HTTP options. * @return RunResponse */ protected function pollRunUntilTerminal( string $agentId, string $runId, int $pollTimeout, int $pollInterval, RequestOptions $options ): array { $httpTransporter = $this->getHttpTransporter(); $deadline = time() + $pollTimeout; do { if (time() >= $deadline) { throw new RuntimeException( sprintf( 'Timed out waiting for Cursor Cloud Agent run after %d seconds.', absint($pollTimeout) ) ); } sleep($pollInterval); $request = new Request( HttpMethodEnum::GET(), CursorProvider::url('v1/agents/' . rawurlencode($agentId) . '/runs/' . rawurlencode($runId)), array('Accept' => 'application/json'), null, $options ); $request = $this->getRequestAuthentication()->authenticateRequest($request); $response = $httpTransporter->send($request); ResponseUtil::throwIfNotSuccessful($response); /** @var RunResponse $runData */ $runData = $response->getData(); $status = isset($runData['status']) ? strtoupper((string) $runData['status']) : ''; $result = $this->runResultText($runData); $finishedWithoutResult = ($status === 'FINISHED' && $result === ''); } while (!in_array($status, self::TERMINAL_STATUSES, true) || $finishedWithoutResult); return $runData; } /** * Extracts assistant result text from a run payload. * * @since 0.1.4 * * @param array $runData Run response data. */ protected function runResultText(array $runData): string { if (isset($runData['result']) && is_string($runData['result'])) { return trim($runData['result']); } return ''; } /** * Archives an agent without failing the generation result. * * @since 0.1.0 */ protected function archiveAgentBestEffort(string $agentId, RequestOptions $options): void { try { $request = new Request( HttpMethodEnum::POST(), CursorProvider::url('v1/agents/' . rawurlencode($agentId) . '/archive'), array('Accept' => 'application/json'), null, $options ); $request = $this->getRequestAuthentication()->authenticateRequest($request); $response = $this->getHttpTransporter()->send($request); ResponseUtil::throwIfNotSuccessful($response); } catch (\Throwable $e) { unset($e); } } /** * Builds HTTP request options with a generous timeout for agent calls. * * The WordPress AI Client prompt builder sets a default 30s timeout. Cloud Agent * create/poll calls need longer, so always raise the timeout for these requests. * * @since 0.1.0 */ protected function buildRequestOptions(int $pollTimeout): RequestOptions { $base = $this->getRequestOptions(); if ($base instanceof RequestOptions) { $options = RequestOptions::fromArray($base->toArray()); } else { $options = new RequestOptions(); } // Individual HTTP calls should not hang forever while polling handles overall wait. $perRequestTimeout = (float) max(60, min(180, $pollTimeout)); $options->setTimeout($perRequestTimeout); return $options; } }