Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 78 additions & 10 deletions src/Runtime/class-wp-agent-runtime-tool-lifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $result Raw submitted result.
* @return array{request: array<string, mixed>, result: array<string, mixed>, 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.
*
Expand Down Expand Up @@ -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<string, mixed> $request Raw stored request.
* @param array<string, mixed> $normalized_request Normalized request identity.
* @return array<string, mixed>|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<string, mixed> $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.
*
Expand Down
12 changes: 11 additions & 1 deletion src/Runtime/class-wp-agent-runtime-tool-request-store.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed>|null Normalized request or null when absent.
Expand All @@ -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<string, mixed> $result Normalized runtime tool result.
*/
Expand Down
5 changes: 3 additions & 2 deletions src/Runtime/class-wp-agent-runtime-tool-request.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
67 changes: 66 additions & 1 deletion tests/tool-runtime-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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();

Expand Down
Loading