diff --git a/src/Runtime/class-wp-agent-runtime-tool-lifecycle.php b/src/Runtime/class-wp-agent-runtime-tool-lifecycle.php index 3c2b1e3..18d448c 100644 --- a/src/Runtime/class-wp-agent-runtime-tool-lifecycle.php +++ b/src/Runtime/class-wp-agent-runtime-tool-lifecycle.php @@ -43,34 +43,84 @@ public static function create_pending_request( WP_Agent_Runtime_Tool_Request_Sto */ public static function submit_result( WP_Agent_Runtime_Tool_Request_Store $store, array $result, $continuation = null, array $context = array() ): array { $request_id = self::request_id_from_payload( $result, 'invalid_runtime_tool_result: request_id must be a non-empty string' ); - $request = $store->get( $request_id ); + $completion = self::complete_if_pending( $store, $request_id, $result ); - if ( null === $request ) { - throw new \InvalidArgumentException( 'invalid_runtime_tool_result: request not found' ); - } - - $normalized_request = WP_Agent_Runtime_Tool_Request::normalize( $request ); - $normalized_result = WP_Agent_Runtime_Tool_Result::from_request( $normalized_request, $result ); - $store->complete( self::string_field( $normalized_request, 'request_id' ), $normalized_result ); + $normalized_request = $completion['request']; + $normalized_result = $completion['result']; + $is_duplicate = $completion['duplicate']; - do_action( 'agents_api_runtime_tool_result_submitted', $normalized_request, $normalized_result, $context ); + if ( ! $is_duplicate ) { + do_action( 'agents_api_runtime_tool_result_submitted', $normalized_request, $normalized_result, $context ); + } $envelope = array( 'status' => WP_Agent_Runtime_Tool_Result::STATUS_SUBMITTED, 'request' => $normalized_request, 'result' => $normalized_result, + 'duplicate' => $is_duplicate, 'tool_result_message' => self::tool_result_message_payload( $normalized_request, $normalized_result ), 'tool_execution_result' => self::tool_execution_result_payload( $normalized_request, $normalized_result ), 'continuation_result' => null, ); - if ( null !== $continuation ) { + if ( null !== $continuation && ! $is_duplicate ) { $envelope['continuation_result'] = self::resume( $continuation, $normalized_request, $normalized_result, $context ); } return $envelope; } + /** + * Complete a pending runtime tool request once, or return a retained result. + * + * Duplicate submissions for terminal records are idempotent only when the + * store can return the original submitted result from `get()` under `result`. + * Otherwise the duplicate is refused before any overwrite can occur. + * + * @param WP_Agent_Runtime_Tool_Request_Store $store Request store. + * @param string $request_id Runtime tool request id. + * @param array $result Raw submitted result. + * @return array{request: array, result: array, completed: bool, duplicate: bool} Completion envelope. + */ + public static function complete_if_pending( WP_Agent_Runtime_Tool_Request_Store $store, string $request_id, array $result ): array { + $request_id = trim( $request_id ); + if ( '' === $request_id ) { + throw new \InvalidArgumentException( 'invalid_runtime_tool_result: request_id must be a non-empty string' ); + } + + $request = $store->get( $request_id ); + if ( null === $request ) { + throw new \InvalidArgumentException( 'invalid_runtime_tool_result: request not found' ); + } + + $status = is_string( $request['status'] ?? null ) ? $request['status'] : ''; + $normalized_request = WP_Agent_Runtime_Tool_Request::normalize( $request ); + + if ( '' !== $status && WP_Agent_Runtime_Tool_Request::STATUS_PENDING !== $status ) { + $stored_result = self::stored_completion_result( $request, $normalized_request ); + if ( null === $stored_result ) { + throw new \InvalidArgumentException( 'invalid_runtime_tool_result: request is not pending' ); + } + + return array( + 'request' => $normalized_request, + 'result' => $stored_result, + 'completed' => false, + 'duplicate' => true, + ); + } + + $normalized_result = WP_Agent_Runtime_Tool_Result::from_request( $normalized_request, $result ); + $store->complete( self::string_field( $normalized_request, 'request_id' ), $normalized_result ); + + return array( + 'request' => $normalized_request, + 'result' => $normalized_result, + 'completed' => true, + 'duplicate' => false, + ); + } + /** * Mark a pending request timed out and optionally resume with a timeout result. * @@ -167,6 +217,24 @@ private static function resume( $continuation, array $request, array $result, ar return $resume_result; } + /** + * Return a retained normalized completion result from a terminal request. + * + * @param array $request Raw stored request. + * @param array $normalized_request Normalized request identity. + * @return array|null Normalized prior result when available. + */ + private static function stored_completion_result( array $request, array $normalized_request ): ?array { + if ( ! isset( $request['result'] ) || ! is_array( $request['result'] ) ) { + return null; + } + + /** @var array $stored_result */ + $stored_result = $request['result']; + + return WP_Agent_Runtime_Tool_Result::from_request( $normalized_request, $stored_result ); + } + /** * Build the transcript-compatible tool-result message payload. * diff --git a/src/Runtime/class-wp-agent-runtime-tool-request-store.php b/src/Runtime/class-wp-agent-runtime-tool-request-store.php index 8d997ef..c916084 100644 --- a/src/Runtime/class-wp-agent-runtime-tool-request-store.php +++ b/src/Runtime/class-wp-agent-runtime-tool-request-store.php @@ -22,7 +22,12 @@ interface WP_Agent_Runtime_Tool_Request_Store { public function create( array $request ): void; /** - * Read a pending runtime tool request by id. + * Read a runtime tool request by id. + * + * Stores may retain terminal records after completion or timeout. Completed + * records that can expose the prior submitted result should keep that + * normalized result under `result` so duplicate submissions can return the + * original completion without overwriting it. * * @param string $request_id Runtime tool request id. * @return array|null Normalized request or null when absent. @@ -32,6 +37,11 @@ public function get( string $request_id ): ?array; /** * Mark a pending request complete with a client-submitted result. * + * Implementations should transition only pending records. Duplicate + * completions for terminal records must leave existing store data unchanged; + * callers use `get()` before this method to return a retained prior result or + * reject the duplicate when no prior result is available. + * * @param string $request_id Runtime tool request id. * @param array $result Normalized runtime tool result. */ diff --git a/src/Runtime/class-wp-agent-runtime-tool-request.php b/src/Runtime/class-wp-agent-runtime-tool-request.php index f0d6e84..71ad596 100644 --- a/src/Runtime/class-wp-agent-runtime-tool-request.php +++ b/src/Runtime/class-wp-agent-runtime-tool-request.php @@ -14,8 +14,9 @@ */ class WP_Agent_Runtime_Tool_Request { - public const STATUS_PENDING = 'runtime_tool_pending'; - public const STATUS_TIMEOUT = 'runtime_tool_timeout'; + public const STATUS_PENDING = 'runtime_tool_pending'; + public const STATUS_COMPLETED = 'runtime_tool_completed'; + public const STATUS_TIMEOUT = 'runtime_tool_timeout'; /** * Normalize a pending runtime tool request. diff --git a/tests/tool-runtime-smoke.php b/tests/tool-runtime-smoke.php index 0e36b62..fec0709 100644 --- a/tests/tool-runtime-smoke.php +++ b/tests/tool-runtime-smoke.php @@ -464,7 +464,8 @@ public function get( string $request_id ): ?array { } public function complete( string $request_id, array $result ): void { - $this->requests[ $request_id ]['status'] = 'completed'; + $this->requests[ $request_id ]['status'] = AgentsAPI\AI\WP_Agent_Runtime_Tool_Request::STATUS_COMPLETED; + $this->requests[ $request_id ]['result'] = $result; $this->results[ $request_id ] = $result; } @@ -507,6 +508,70 @@ static function ( array $request, array $result, array $context ) use ( &$contin agents_api_smoke_assert_equals( true, $submission['continuation_result']['resumed'] ?? false, 'lifecycle invokes continuation callback after submission', $failures, $passes ); agents_api_smoke_assert_equals( 'smoke', $continuation_calls[0]['context']['resume_source'] ?? '', 'continuation receives caller context', $failures, $passes ); +$duplicate_submission = AgentsAPI\AI\WP_Agent_Runtime_Tool_Lifecycle::submit_result( + $runtime_tool_store, + array( + 'request_id' => $pending_request['request_id'], + 'success' => true, + 'result' => array( 'post_id' => 999 ), + ), + static function ( array $request, array $result, array $context ) use ( &$continuation_calls ): array { + $continuation_calls[] = compact( 'request', 'result', 'context' ); + return array( 'resumed' => true ); + } +); +agents_api_smoke_assert_equals( true, $duplicate_submission['duplicate'] ?? false, 'duplicate runtime tool completion is reported as duplicate', $failures, $passes ); +agents_api_smoke_assert_equals( 456, $duplicate_submission['result']['result']['post_id'] ?? null, 'duplicate runtime tool completion returns prior result', $failures, $passes ); +agents_api_smoke_assert_equals( 456, $runtime_tool_store->requests[ $pending_request['request_id'] ]['result']['result']['post_id'] ?? null, 'duplicate runtime tool completion does not overwrite stored result', $failures, $passes ); +agents_api_smoke_assert_equals( 1, count( $continuation_calls ), 'duplicate runtime tool completion does not resume again', $failures, $passes ); + +$terminal_without_result_store = new class() implements AgentsAPI\AI\WP_Agent_Runtime_Tool_Request_Store { + public array $requests = array(); + public int $complete_calls = 0; + public array $attempted_data = array(); + + public function create( array $request ): void { + $request['status'] = AgentsAPI\AI\WP_Agent_Runtime_Tool_Request::STATUS_COMPLETED; + $this->requests[ $request['request_id'] ] = $request; + } + + public function get( string $request_id ): ?array { + return $this->requests[ $request_id ] ?? null; + } + + public function complete( string $request_id, array $result ): void { + ++$this->complete_calls; + $this->attempted_data = compact( 'request_id', 'result' ); + } + + public function timeout( string $request_id ): void { + $this->requests[ $request_id ]['status'] = AgentsAPI\AI\WP_Agent_Runtime_Tool_Request::STATUS_TIMEOUT; + } + + public function recent_pending( array $query = array() ): array { + unset( $query ); + return array(); + } +}; + +$terminal_without_result_request = AgentsAPI\AI\WP_Agent_Runtime_Tool_Request::from_tool_call( 'client/choose_post', 'call_terminal', array(), array( 'run_id' => 'run-terminal' ) ); +$terminal_without_result_store->create( $terminal_without_result_request ); +$duplicate_refused = false; +try { + AgentsAPI\AI\WP_Agent_Runtime_Tool_Lifecycle::submit_result( + $terminal_without_result_store, + array( + 'request_id' => $terminal_without_result_request['request_id'], + 'success' => true, + 'result' => array( 'post_id' => 789 ), + ) + ); +} catch ( InvalidArgumentException $e ) { + $duplicate_refused = 'invalid_runtime_tool_result: request is not pending' === $e->getMessage(); +} +agents_api_smoke_assert_equals( true, $duplicate_refused, 'duplicate runtime tool completion without prior result is refused', $failures, $passes ); +agents_api_smoke_assert_equals( 0, $terminal_without_result_store->complete_calls, 'refused duplicate runtime tool completion does not write', $failures, $passes ); + $timeout_store = new class() implements AgentsAPI\AI\WP_Agent_Runtime_Tool_Request_Store { public array $requests = array();