diff --git a/composer.json b/composer.json index e103b52..0b9b282 100644 --- a/composer.json +++ b/composer.json @@ -89,6 +89,7 @@ "php tests/conversation-loop-budgets-smoke.php", "php tests/runtime-package-run-contract-smoke.php", "php tests/run-control-normalization-smoke.php", + "php tests/canonical-run-lifecycle-smoke.php", "php tests/channels-smoke.php", "php tests/chat-run-control-smoke.php", "php tests/task-execution-smoke.php", diff --git a/src/Runtime/class-wp-agent-conversation-loop.php b/src/Runtime/class-wp-agent-conversation-loop.php index 155a96b..f1deea4 100644 --- a/src/Runtime/class-wp-agent-conversation-loop.php +++ b/src/Runtime/class-wp-agent-conversation-loop.php @@ -180,6 +180,13 @@ public static function run( array $messages, ?callable $turn_runner = null, arra $turns_run = $turn; $turn_context = $context; $turn_context['turn'] = $turn; + $interrupt = self::check_runtime_cancellation( $run_id, $lock_session_id, $turn_context, $on_event ); + if ( null !== $interrupt ) { + $messages[] = $interrupt['message']; + $events[] = self::interrupt_event( $interrupt ); + $interrupted = $interrupt['metadata']; + break; + } self::emit_event( $on_event, 'turn_started', array( 'turn' => $turn, @@ -238,6 +245,14 @@ public static function run( array $messages, ?callable $turn_runner = null, arra throw $error; } + $interrupt = self::check_runtime_cancellation( $run_id, $lock_session_id, $turn_context, $on_event ); + if ( null !== $interrupt ) { + $messages[] = $interrupt['message']; + $events[] = self::interrupt_event( $interrupt ); + $interrupted = $interrupt['metadata']; + break; + } + if ( isset( $result['provider_diagnostics'] ) && is_array( $result['provider_diagnostics'] ) ) { $provider_diagnostics[] = self::normalize_assoc_array( $result['provider_diagnostics'] ); } @@ -291,6 +306,14 @@ public static function run( array $messages, ?callable $turn_runner = null, arra // When mediation is enabled, the turn runner returns tool_calls // and the loop handles execution. Otherwise, the caller-managed path applies. if ( null !== $tool_executor && $mediation_enabled && isset( $result['tool_calls'] ) && is_array( $result['tool_calls'] ) ) { + $interrupt = self::check_runtime_cancellation( $run_id, $lock_session_id, $turn_context, $on_event ); + if ( null !== $interrupt ) { + $messages[] = $interrupt['message']; + $events[] = self::interrupt_event( $interrupt ); + $interrupted = $interrupt['metadata']; + break; + } + $mediation_result = WP_Agent_Tool_Mediation_Runner::run( $messages, self::normalize_assoc_array( $result ), @@ -321,6 +344,13 @@ public static function run( array $messages, ?callable $turn_runner = null, arra $approval_required = $mediation_result['approval_required'] ?? null; $runtime_tool_pending = $mediation_result['runtime_tool_pending'] ?? null; $stalled = self::check_spin_detector( $spin_detector, $mediation_result['spin_signatures'], $turn_context, $on_event ); + $interrupt = self::check_runtime_cancellation( $run_id, $lock_session_id, $turn_context, $on_event ); + if ( null !== $interrupt ) { + $messages[] = $interrupt['message']; + $events[] = self::interrupt_event( $interrupt ); + $interrupted = $interrupt['metadata']; + break; + } } else { // Caller-managed path: turn runner handles everything internally. $result = self::normalize_conversation_result( $result ); @@ -379,18 +409,12 @@ public static function run( array $messages, ?callable $turn_runner = null, arra } $interrupt = self::check_interrupt_source( $interrupt_source, $messages, $options, $turn_context, $on_event ); - if ( null === $interrupt && '' !== $run_id ) { - $interrupt_message = WP_Agent_Chat_Run_Control::cancellation_interrupt_for_run( $run_id, $lock_session_id ); - if ( null !== $interrupt_message ) { - $interrupt = self::normalize_interrupt_message( $interrupt_message, $turn_context, $on_event ); - } + if ( null === $interrupt ) { + $interrupt = self::check_runtime_cancellation( $run_id, $lock_session_id, $turn_context, $on_event ); } if ( null !== $interrupt ) { $messages[] = $interrupt['message']; - $events[] = array( - 'type' => 'interrupt_received', - 'metadata' => $interrupt['metadata'], - ); + $events[] = self::interrupt_event( $interrupt ); if ( 'cancel' === $interrupt['action'] ) { $interrupted = $interrupt['metadata']; @@ -1342,6 +1366,36 @@ private static function check_interrupt_source( ?callable $interrupt_source, arr return self::normalize_interrupt_message( self::normalize_assoc_array( $message ), $context, $on_event ); } + /** + * Check canonical chat run-control cancellation state. + * + * @param array $context Current turn context. + * @return array{message: array, metadata: array, action: string}|null + */ + private static function check_runtime_cancellation( string $run_id, string $lock_session_id, array $context, ?callable $on_event ): ?array { + if ( '' === $run_id ) { + return null; + } + + $interrupt_message = WP_Agent_Chat_Run_Control::cancellation_interrupt_for_run( $run_id, $lock_session_id ); + if ( null === $interrupt_message ) { + return null; + } + + return self::normalize_interrupt_message( $interrupt_message, $context, $on_event ); + } + + /** + * @param array{message: array, metadata: array, action: string} $interrupt Interrupt payload. + * @return array + */ + private static function interrupt_event( array $interrupt ): array { + return array( + 'type' => 'interrupt_received', + 'metadata' => $interrupt['metadata'], + ); + } + /** * Normalize and emit a received interrupt message. * diff --git a/src/Runtime/class-wp-agent-option-run-control-store.php b/src/Runtime/class-wp-agent-option-run-control-store.php index 7715dcb..3288ea2 100644 --- a/src/Runtime/class-wp-agent-option-run-control-store.php +++ b/src/Runtime/class-wp-agent-option-run-control-store.php @@ -16,7 +16,7 @@ class WP_Agent_Option_Run_Control_Store implements WP_Agent_Run_Control_Store { /** * @param string $store_key Store key. - * @return array{runs:array>,queues:array>>} + * @return array{runs:array>,queues:array>>,events:array>>} */ public function get_state( string $store_key ): array { $state = function_exists( 'get_option' ) ? get_option( $store_key, array() ) : array(); @@ -27,12 +27,13 @@ public function get_state( string $store_key ): array { return array( 'runs' => $this->stored_runs( $state['runs'] ?? array() ), 'queues' => $this->stored_queues( $state['queues'] ?? array() ), + 'events' => $this->stored_queues( $state['events'] ?? array() ), ); } /** * @param string $store_key Store key. - * @param array{runs:array>,queues:array>>} $state State envelope. + * @param array{runs:array>,queues:array>>,events:array>>} $state State envelope. */ public function save_state( string $store_key, array $state ): void { if ( function_exists( 'update_option' ) ) { diff --git a/src/Runtime/class-wp-agent-run-control.php b/src/Runtime/class-wp-agent-run-control.php index feb0dab..8b28e88 100644 --- a/src/Runtime/class-wp-agent-run-control.php +++ b/src/Runtime/class-wp-agent-run-control.php @@ -238,6 +238,7 @@ public static function start_run( string $store_key, string $run_id, array $run $state = self::state( $store_key ); $state['runs'][ $run_id ] = $run; + $state = self::record_event_in_state( $state, $run_id, 'run_started', array( 'status' => self::STATUS_RUNNING ) ); self::save_state( $store_key, $state ); return self::normalize_run( $run ); @@ -257,6 +258,7 @@ public static function save_run( string $store_key, array $run ): array { $state = self::state( $store_key ); $state['runs'][ $run_id ] = $normalized; + $state = self::record_event_in_state( $state, $run_id, 'run_updated', array( 'status' => $normalized['status'] ) ); self::save_state( $store_key, $state ); return $normalized; @@ -284,6 +286,7 @@ public static function finish_run( string $store_key, string $run_id, string $st } $state['runs'][ $run_id ] = $run; + $state = self::record_event_in_state( $state, $run_id, 'run_finished', array( 'status' => $run['status'] ) ); self::save_state( $store_key, $state ); return self::normalize_run( $run ); @@ -318,6 +321,7 @@ public static function request_cancel( string $store_key, string $run_id ): ?arr $run['updated_at'] = self::now(); $state['runs'][ $run_id ] = $run; + $state = self::record_event_in_state( $state, $run_id, 'cancel_requested', array( 'status' => $run['status'] ) ); self::save_state( $store_key, $state ); return self::normalize_run( $run ); @@ -328,6 +332,32 @@ public static function cancel_requested( string $store_key, string $run_id ): bo return null !== $run && self::STATUS_CANCELLING === ( $run['status'] ?? '' ); } + /** + * @return array|null + */ + public static function list_events( string $store_key, string $run_id, string $cursor = '', int $limit = 100 ): ?array { + $run = self::get_run( $store_key, $run_id ); + if ( null === $run ) { + return null; + } + + $state = self::state( $store_key ); + $events = array_values( $state['events'][ $run_id ] ?? array() ); + $offset = max( 0, self::int_value( $cursor ) ); + $limit = max( 1, min( 500, $limit ) ); + $page = array_slice( $events, $offset, $limit ); + $next = $offset + count( $page ); + + return array_merge( + $run, + array( + 'events' => array_map( array( self::class, 'normalize_event' ), $page ), + 'cursor' => $next < count( $events ) ? (string) $next : '', + 'has_more' => $next < count( $events ), + ) + ); + } + public static function store(): WP_Agent_Run_Control_Store { if ( null === self::$store ) { self::$store = new WP_Agent_Option_Run_Control_Store(); @@ -344,13 +374,13 @@ public static function reset_store(): void { self::$store = null; } - /** @return array{runs:array>,queues:array>>} */ + /** @return array{runs:array>,queues:array>>,events:array>>} */ public static function state( string $store_key ): array { return self::store()->get_state( $store_key ); } /** - * @param array{runs:array>,queues:array>>} $state + * @param array{runs:array>,queues:array>>,events:array>>} $state */ public static function save_state( string $store_key, array $state ): void { self::store()->save_state( $store_key, $state ); @@ -382,4 +412,22 @@ public static function string_keyed_array( array $value ): array { private static function int_value( mixed $value ): int { return is_int( $value ) || is_float( $value ) || is_string( $value ) || is_bool( $value ) ? (int) $value : 0; } + + /** + * @param array{runs:array>,queues:array>>,events:array>>} $state + * @param array $metadata Event metadata. + * @return array{runs:array>,queues:array>>,events:array>>} + */ + private static function record_event_in_state( array $state, string $run_id, string $type, array $metadata = array() ): array { + $events = array_values( $state['events'][ $run_id ] ?? array() ); + $events[] = array( + 'id' => $run_id . ':' . count( $events ), + 'type' => $type, + 'created_at' => self::now(), + 'metadata' => self::string_keyed_array( $metadata ), + ); + $state['events'][ $run_id ] = $events; + + return $state; + } } diff --git a/src/Runtime/interface-wp-agent-run-control-store.php b/src/Runtime/interface-wp-agent-run-control-store.php index f67c43b..a5f6738 100644 --- a/src/Runtime/interface-wp-agent-run-control-store.php +++ b/src/Runtime/interface-wp-agent-run-control-store.php @@ -18,7 +18,7 @@ interface WP_Agent_Run_Control_Store { * Read the state for a store key. * * @param string $store_key Store key. - * @return array{runs:array>,queues:array>>} + * @return array{runs:array>,queues:array>>,events:array>>} */ public function get_state( string $store_key ): array; @@ -26,7 +26,7 @@ public function get_state( string $store_key ): array; * Save the state for a store key. * * @param string $store_key Store key. - * @param array{runs:array>,queues:array>>} $state State envelope. + * @param array{runs:array>,queues:array>>,events:array>>} $state State envelope. */ public function save_state( string $store_key, array $state ): void; } diff --git a/src/Runtime/register-runtime-package-run-ability.php b/src/Runtime/register-runtime-package-run-ability.php index 9a4c7c7..f716023 100644 --- a/src/Runtime/register-runtime-package-run-ability.php +++ b/src/Runtime/register-runtime-package-run-ability.php @@ -9,7 +9,15 @@ defined( 'ABSPATH' ) || exit; -const AGENTS_RUN_RUNTIME_PACKAGE_ABILITY = 'agents/run-runtime-package'; +require_once __DIR__ . '/interface-wp-agent-run-control-store.php'; +require_once __DIR__ . '/class-wp-agent-option-run-control-store.php'; +require_once __DIR__ . '/class-wp-agent-run-control.php'; + +const AGENTS_RUN_RUNTIME_PACKAGE_ABILITY = 'agents/run-runtime-package'; +const AGENTS_GET_RUNTIME_PACKAGE_RUN_ABILITY = 'agents/get-runtime-package-run'; +const AGENTS_CANCEL_RUNTIME_PACKAGE_RUN_ABILITY = 'agents/cancel-runtime-package-run'; +const AGENTS_LIST_RUNTIME_PACKAGE_RUN_EVENTS_ABILITY = 'agents/list-runtime-package-run-events'; +const AGENTS_RUNTIME_PACKAGE_RUN_CONTROL_STORE = 'agents_api_runtime_package_run_control'; add_action( 'wp_abilities_api_categories_init', @@ -31,29 +39,73 @@ static function (): void { add_action( 'wp_abilities_api_init', static function (): void { - if ( wp_has_ability( AGENTS_RUN_RUNTIME_PACKAGE_ABILITY ) ) { - return; - } - - wp_register_ability( - AGENTS_RUN_RUNTIME_PACKAGE_ABILITY, - array( - 'label' => 'Run Runtime Package', - 'description' => 'Canonical entry point for running a portable agent package workflow. Dispatches to a consumer-provided runtime handler.', - 'category' => 'agents-api', - 'input_schema' => agents_runtime_package_run_input_schema(), - 'output_schema' => agents_runtime_package_run_output_schema(), - 'execute_callback' => __NAMESPACE__ . '\agents_runtime_package_run_dispatch', - 'permission_callback' => __NAMESPACE__ . '\agents_runtime_package_run_permission', - 'meta' => array( - 'show_in_rest' => true, - 'annotations' => array( - 'destructive' => true, - 'idempotent' => false, - ), + $abilities = array( + AGENTS_RUN_RUNTIME_PACKAGE_ABILITY => array( + 'label' => 'Run Runtime Package', + 'description' => 'Canonical entry point for running a portable agent package workflow. Dispatches to a consumer-provided runtime handler.', + 'input_schema' => agents_runtime_package_run_input_schema(), + 'output_schema' => agents_runtime_package_run_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_runtime_package_run_dispatch', + 'permission' => __NAMESPACE__ . '\\agents_runtime_package_run_permission', + 'annotations' => array( + 'destructive' => true, + 'idempotent' => false, ), - ) + ), + AGENTS_GET_RUNTIME_PACKAGE_RUN_ABILITY => array( + 'label' => 'Get Runtime Package Run', + 'description' => 'Read the canonical status envelope for an addressable runtime package run.', + 'input_schema' => agents_runtime_package_run_id_input_schema(), + 'output_schema' => agents_runtime_package_run_control_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_get_runtime_package_run', + 'permission' => __NAMESPACE__ . '\\agents_runtime_package_run_read_permission', + 'annotations' => array( 'idempotent' => true ), + ), + AGENTS_CANCEL_RUNTIME_PACKAGE_RUN_ABILITY => array( + 'label' => 'Cancel Runtime Package Run', + 'description' => 'Request best-effort cancellation for an addressable runtime package run.', + 'input_schema' => agents_runtime_package_run_id_input_schema(), + 'output_schema' => agents_runtime_package_run_control_output_schema( true ), + 'execute_callback' => __NAMESPACE__ . '\\agents_cancel_runtime_package_run', + 'permission' => __NAMESPACE__ . '\\agents_runtime_package_run_cancel_permission', + 'annotations' => array( + 'destructive' => true, + 'idempotent' => true, + ), + ), + AGENTS_LIST_RUNTIME_PACKAGE_RUN_EVENTS_ABILITY => array( + 'label' => 'List Runtime Package Run Events', + 'description' => 'List canonical lifecycle events for an addressable runtime package run.', + 'input_schema' => agents_runtime_package_run_events_input_schema(), + 'output_schema' => agents_runtime_package_run_events_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_list_runtime_package_run_events', + 'permission' => __NAMESPACE__ . '\\agents_runtime_package_run_read_permission', + 'annotations' => array( 'idempotent' => true ), + ), ); + + foreach ( $abilities as $ability => $args ) { + if ( wp_has_ability( $ability ) ) { + continue; + } + + wp_register_ability( + $ability, + array( + 'label' => $args['label'], + 'description' => $args['description'], + 'category' => 'agents-api', + 'input_schema' => $args['input_schema'], + 'output_schema' => $args['output_schema'], + 'execute_callback' => $args['execute_callback'], + 'permission_callback' => $args['permission'], + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => $args['annotations'], + ), + ) + ); + } } ); @@ -64,12 +116,30 @@ static function (): void { * @return array|\WP_Error */ function agents_runtime_package_run_dispatch( array $input ) { + $run_id = agents_runtime_package_run_string( $input['run_id'] ?? '' ); + $run_id = '' !== $run_id ? $run_id : WP_Agent_Run_Control::generate_run_id( 'runtime_run_' ); + $input['run_id'] = $run_id; + $options = is_array( $input['options'] ?? null ) ? agents_runtime_package_run_string_keyed_array( $input['options'] ) : array(); + $options['run_id'] = $run_id; + $input['options'] = $options; + $request = WP_Agent_Runtime_Package_Run_Request::from_array( $input ); if ( is_wp_error( $request ) ) { do_action( 'agents_runtime_package_run_dispatch_failed', $request->get_error_code(), $input ); return $request; } + WP_Agent_Run_Control::start_run( + AGENTS_RUNTIME_PACKAGE_RUN_CONTROL_STORE, + $run_id, + array( + 'metadata' => array( + 'package' => $request->get_package(), + 'workflow' => $request->get_workflow(), + ), + ) + ); + /** * Filters the runtime package execution handler. * @@ -82,6 +152,7 @@ function agents_runtime_package_run_dispatch( array $input ) { */ $handler = apply_filters( 'wp_agent_runtime_package_run_handler', null, $request, $input ); if ( ! is_callable( $handler ) ) { + WP_Agent_Run_Control::finish_run( AGENTS_RUNTIME_PACKAGE_RUN_CONTROL_STORE, $run_id, WP_Agent_Run_Control::STATUS_FAILED ); do_action( 'agents_runtime_package_run_dispatch_failed', 'no_handler', $input ); return new \WP_Error( 'agents_runtime_package_run_no_handler', @@ -91,15 +162,15 @@ function agents_runtime_package_run_dispatch( array $input ) { $result = call_user_func( $handler, $request, $input ); if ( is_wp_error( $result ) ) { + WP_Agent_Run_Control::finish_run( AGENTS_RUNTIME_PACKAGE_RUN_CONTROL_STORE, $run_id, WP_Agent_Run_Control::STATUS_FAILED ); do_action( 'agents_runtime_package_run_dispatch_failed', $result->get_error_code(), $input ); return $result; } if ( $result instanceof WP_Agent_Runtime_Package_Run_Result ) { - return $result->to_array(); - } - - if ( ! is_array( $result ) ) { + $result = $result->to_array(); + } elseif ( ! is_array( $result ) ) { + WP_Agent_Run_Control::finish_run( AGENTS_RUNTIME_PACKAGE_RUN_CONTROL_STORE, $run_id, WP_Agent_Run_Control::STATUS_FAILED ); do_action( 'agents_runtime_package_run_dispatch_failed', 'invalid_result', $input ); return new \WP_Error( 'agents_runtime_package_run_invalid_result', @@ -107,7 +178,84 @@ function agents_runtime_package_run_dispatch( array $input ) { ); } - return WP_Agent_Runtime_Package_Run_Result::from_array( $result )->to_array(); + $result = agents_runtime_package_run_string_keyed_array( $result ); + $result['run_id'] = $run_id; + $normalized = WP_Agent_Runtime_Package_Run_Result::from_array( $result )->to_array(); + $status = WP_Agent_Run_Control::normalize_status( $normalized['status'] ?? WP_Agent_Run_Control::STATUS_SUCCEEDED ); + WP_Agent_Run_Control::save_run( + AGENTS_RUNTIME_PACKAGE_RUN_CONTROL_STORE, + array( + 'run_id' => $run_id, + 'status' => $status, + 'metadata' => array( + 'package' => $request->get_package(), + 'workflow' => $request->get_workflow(), + ), + 'started_at' => WP_Agent_Run_Control::now(), + ) + ); + + return $normalized; +} + +/** + * @param array $input Ability input. + * @return array|\WP_Error + */ +function agents_get_runtime_package_run( array $input ) { + $handler = apply_filters( 'wp_agent_runtime_package_run_status_handler', null, $input ); + if ( is_callable( $handler ) ) { + return WP_Agent_Run_Control::normalize_run_result( call_user_func( $handler, $input ), 'agents_runtime_package_run_invalid_status' ); + } + + $run = WP_Agent_Run_Control::get_run( AGENTS_RUNTIME_PACKAGE_RUN_CONTROL_STORE, agents_runtime_package_run_string( $input['run_id'] ?? '' ) ); + if ( null === $run ) { + return new \WP_Error( 'agents_runtime_package_run_not_found', 'No runtime package run was found for the requested run_id.' ); + } + + return $run; +} + +/** + * @param array $input Ability input. + * @return array|\WP_Error + */ +function agents_cancel_runtime_package_run( array $input ) { + $handler = apply_filters( 'wp_agent_runtime_package_run_cancel_handler', null, $input ); + if ( is_callable( $handler ) ) { + $result = WP_Agent_Run_Control::normalize_cancel_result( call_user_func( $handler, $input ), 'agents_runtime_package_run_invalid_cancel_result' ); + } else { + $result = WP_Agent_Run_Control::request_cancel( AGENTS_RUNTIME_PACKAGE_RUN_CONTROL_STORE, agents_runtime_package_run_string( $input['run_id'] ?? '' ) ); + if ( null === $result ) { + return new \WP_Error( 'agents_runtime_package_run_not_found', 'No runtime package run was found for the requested run_id.' ); + } + $result = WP_Agent_Run_Control::normalize_cancel_result( $result, 'agents_runtime_package_run_invalid_cancel_result' ); + } + + return $result; +} + +/** + * @param array $input Ability input. + * @return array|\WP_Error + */ +function agents_list_runtime_package_run_events( array $input ) { + $handler = apply_filters( 'wp_agent_runtime_package_run_events_handler', null, $input ); + if ( is_callable( $handler ) ) { + return WP_Agent_Run_Control::normalize_events_result( call_user_func( $handler, $input ), 'agents_runtime_package_run_invalid_events_result' ); + } + + $result = WP_Agent_Run_Control::list_events( + AGENTS_RUNTIME_PACKAGE_RUN_CONTROL_STORE, + agents_runtime_package_run_string( $input['run_id'] ?? '' ), + agents_runtime_package_run_string( $input['cursor'] ?? '' ), + '' !== agents_runtime_package_run_string( $input['limit'] ?? '' ) ? (int) agents_runtime_package_run_string( $input['limit'] ?? '' ) : 100 + ); + if ( null === $result ) { + return new \WP_Error( 'agents_runtime_package_run_not_found', 'No runtime package run was found for the requested run_id.' ); + } + + return $result; } /** @@ -127,6 +275,17 @@ function agents_runtime_package_run_permission( array $input ): bool { return (bool) apply_filters( 'agents_runtime_package_run_permission', $allowed, $input ); } +/** @param array $input Ability input. */ +function agents_runtime_package_run_read_permission( array $input ): bool { + $allowed = function_exists( 'current_user_can' ) ? current_user_can( 'read' ) : false; + return (bool) apply_filters( 'agents_runtime_package_run_read_permission', $allowed, $input ); +} + +/** @param array $input Ability input. */ +function agents_runtime_package_run_cancel_permission( array $input ): bool { + return agents_runtime_package_run_permission( $input ); +} + /** @return array */ function agents_runtime_package_run_input_schema(): array { return array( @@ -145,6 +304,7 @@ function agents_runtime_package_run_input_schema(): array { 'options' => array( 'type' => 'object' ), 'metadata' => array( 'type' => 'object' ), 'replay' => array( 'type' => 'object' ), + 'run_id' => array( 'type' => array( 'string', 'null' ) ), ), ); } @@ -171,3 +331,86 @@ function agents_runtime_package_run_output_schema(): array { ), ); } + +/** @return array */ +function agents_runtime_package_run_id_input_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'run_id' ), + 'properties' => array( + 'run_id' => array( 'type' => 'string' ), + ), + ); +} + +/** @return array */ +function agents_runtime_package_run_events_input_schema(): array { + $schema = agents_runtime_package_run_id_input_schema(); + $properties = is_array( $schema['properties'] ?? null ) ? $schema['properties'] : array(); + $properties['cursor'] = array( 'type' => 'string' ); + $properties['limit'] = array( 'type' => 'integer' ); + $schema['properties'] = $properties; + return $schema; +} + +/** @return array */ +function agents_runtime_package_run_control_output_schema( bool $include_cancelled = false ): array { + $properties = array( + 'run_id' => array( 'type' => 'string' ), + 'status' => array( + 'type' => 'string', + 'enum' => WP_Agent_Run_Control::statuses(), + ), + 'started_at' => array( 'type' => 'string' ), + 'updated_at' => array( 'type' => 'string' ), + 'metadata' => array( 'type' => 'object' ), + ); + $required = array( 'run_id', 'status', 'started_at', 'updated_at', 'metadata' ); + if ( $include_cancelled ) { + $required[] = 'cancelled'; + $properties['cancelled'] = array( 'type' => 'boolean' ); + } + + return array( + 'type' => 'object', + 'required' => $required, + 'properties' => $properties, + ); +} + +/** @return array */ +function agents_runtime_package_run_events_output_schema(): array { + $schema = agents_runtime_package_run_control_output_schema(); + $required = is_array( $schema['required'] ?? null ) ? array_values( $schema['required'] ) : array(); + $properties = is_array( $schema['properties'] ?? null ) ? $schema['properties'] : array(); + $required[] = 'events'; + $required[] = 'cursor'; + $required[] = 'has_more'; + $properties['events'] = array( + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ); + $properties['cursor'] = array( 'type' => 'string' ); + $properties['has_more'] = array( 'type' => 'boolean' ); + $schema['required'] = $required; + $schema['properties'] = $properties; + return $schema; +} + +function agents_runtime_package_run_string( mixed $value ): string { + return is_scalar( $value ) ? trim( (string) $value ) : ''; +} + +/** + * @param array $data Raw array. + * @return array + */ +function agents_runtime_package_run_string_keyed_array( array $data ): array { + $result = array(); + foreach ( $data as $key => $value ) { + if ( is_string( $key ) ) { + $result[ $key ] = $value; + } + } + return $result; +} diff --git a/src/Workflows/register-agents-workflow-abilities.php b/src/Workflows/register-agents-workflow-abilities.php index 1fabb63..2b26489 100644 --- a/src/Workflows/register-agents-workflow-abilities.php +++ b/src/Workflows/register-agents-workflow-abilities.php @@ -24,6 +24,9 @@ const AGENTS_RUN_WORKFLOW_ABILITY = 'agents/run-workflow'; const AGENTS_VALIDATE_WORKFLOW_ABILITY = 'agents/validate-workflow'; const AGENTS_DESCRIBE_WORKFLOW_ABILITY = 'agents/describe-workflow'; +const AGENTS_GET_WORKFLOW_RUN_ABILITY = 'agents/get-workflow-run'; +const AGENTS_CANCEL_WORKFLOW_RUN_ABILITY = 'agents/cancel-workflow-run'; +const AGENTS_LIST_WORKFLOW_RUN_EVENTS_ABILITY = 'agents/list-workflow-run-events'; add_action( 'wp_abilities_api_categories_init', @@ -142,6 +145,62 @@ static function (): void { ) ); } + + $run_control_abilities = array( + AGENTS_GET_WORKFLOW_RUN_ABILITY => array( + 'label' => 'Get Workflow Run', + 'description' => 'Read the canonical status envelope for an addressable workflow run.', + 'input_schema' => agents_workflow_run_id_input_schema(), + 'output_schema' => agents_workflow_run_control_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_get_workflow_run', + 'permission' => __NAMESPACE__ . '\\agents_workflow_run_read_permission', + 'annotations' => array( 'idempotent' => true ), + ), + AGENTS_CANCEL_WORKFLOW_RUN_ABILITY => array( + 'label' => 'Cancel Workflow Run', + 'description' => 'Request best-effort cancellation for an addressable workflow run.', + 'input_schema' => agents_workflow_run_id_input_schema(), + 'output_schema' => agents_workflow_run_control_output_schema( true ), + 'execute_callback' => __NAMESPACE__ . '\\agents_cancel_workflow_run', + 'permission' => __NAMESPACE__ . '\\agents_workflow_run_cancel_permission', + 'annotations' => array( + 'destructive' => true, + 'idempotent' => true, + ), + ), + AGENTS_LIST_WORKFLOW_RUN_EVENTS_ABILITY => array( + 'label' => 'List Workflow Run Events', + 'description' => 'List canonical lifecycle events for an addressable workflow run.', + 'input_schema' => agents_workflow_run_events_input_schema(), + 'output_schema' => agents_workflow_run_events_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_list_workflow_run_events', + 'permission' => __NAMESPACE__ . '\\agents_workflow_run_read_permission', + 'annotations' => array( 'idempotent' => true ), + ), + ); + + foreach ( $run_control_abilities as $ability => $args ) { + if ( wp_has_ability( $ability ) ) { + continue; + } + + wp_register_ability( + $ability, + array( + 'label' => $args['label'], + 'description' => $args['description'], + 'category' => 'agents-api', + 'input_schema' => $args['input_schema'], + 'output_schema' => $args['output_schema'], + 'execute_callback' => $args['execute_callback'], + 'permission_callback' => $args['permission'], + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => $args['annotations'], + ), + ) + ); + } } ); @@ -248,6 +307,67 @@ function agents_describe_workflow( array $input ): array { ); } +/** + * @param array $input Ability input. + * @return array|\WP_Error + */ +function agents_get_workflow_run( array $input ) { + $handler = apply_filters( 'wp_agent_workflow_run_status_handler', null, $input ); + if ( is_callable( $handler ) ) { + return \AgentsAPI\AI\WP_Agent_Run_Control::normalize_run_result( call_user_func( $handler, $input ), 'agents_workflow_run_invalid_status' ); + } + + $run = \AgentsAPI\AI\WP_Agent_Run_Control::get_run( WP_Agent_Workflow_Runner::RUN_CONTROL_STORE, agents_workflow_string( $input['run_id'] ?? '' ) ); + if ( null === $run ) { + return new \WP_Error( 'agents_workflow_run_not_found', 'No workflow run was found for the requested run_id.' ); + } + + return $run; +} + +/** + * @param array $input Ability input. + * @return array|\WP_Error + */ +function agents_cancel_workflow_run( array $input ) { + $handler = apply_filters( 'wp_agent_workflow_run_cancel_handler', null, $input ); + if ( is_callable( $handler ) ) { + $result = \AgentsAPI\AI\WP_Agent_Run_Control::normalize_cancel_result( call_user_func( $handler, $input ), 'agents_workflow_run_invalid_cancel_result' ); + } else { + $result = \AgentsAPI\AI\WP_Agent_Run_Control::request_cancel( WP_Agent_Workflow_Runner::RUN_CONTROL_STORE, agents_workflow_string( $input['run_id'] ?? '' ) ); + if ( null === $result ) { + return new \WP_Error( 'agents_workflow_run_not_found', 'No workflow run was found for the requested run_id.' ); + } + $result = \AgentsAPI\AI\WP_Agent_Run_Control::normalize_cancel_result( $result, 'agents_workflow_run_invalid_cancel_result' ); + } + + return $result; +} + +/** + * @param array $input Ability input. + * @return array|\WP_Error + */ +function agents_list_workflow_run_events( array $input ) { + $handler = apply_filters( 'wp_agent_workflow_run_events_handler', null, $input ); + if ( is_callable( $handler ) ) { + return \AgentsAPI\AI\WP_Agent_Run_Control::normalize_events_result( call_user_func( $handler, $input ), 'agents_workflow_run_invalid_events_result' ); + } + + $result = \AgentsAPI\AI\WP_Agent_Run_Control::list_events( + WP_Agent_Workflow_Runner::RUN_CONTROL_STORE, + agents_workflow_string( $input['run_id'] ?? '' ), + agents_workflow_string( $input['cursor'] ?? '' ), + '' !== agents_workflow_string( $input['limit'] ?? '' ) ? (int) agents_workflow_string( $input['limit'] ?? '' ) : 100 + ); + + if ( null === $result ) { + return new \WP_Error( 'agents_workflow_run_not_found', 'No workflow run was found for the requested run_id.' ); + } + + return $result; +} + /** * Permission gate for the workflow abilities. Same default as * `agents/chat`: `manage_options`. Consumers with their own auth model @@ -303,6 +423,18 @@ function agents_validate_workflow_permission( array $input ): bool { ); } +/** @param array $input Ability input. */ +function agents_workflow_run_read_permission( array $input ): bool { + $allowed = function_exists( 'current_user_can' ) ? current_user_can( 'read' ) : false; + return (bool) apply_filters( 'agents_workflow_run_read_permission', $allowed, $input ); +} + +/** @param array $input Ability input. */ +function agents_workflow_run_cancel_permission( array $input ): bool { + $allowed = function_exists( 'current_user_can' ) ? current_user_can( 'manage_options' ) : false; + return (bool) apply_filters( 'agents_workflow_run_cancel_permission', $allowed, $input ); +} + /** * Canonical input schema for `agents/run-workflow`. * @@ -379,6 +511,76 @@ function agents_run_workflow_output_schema(): array { ); } +/** @return array */ +function agents_workflow_run_id_input_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'run_id' ), + 'properties' => array( + 'run_id' => array( 'type' => 'string' ), + ), + ); +} + +/** @return array */ +function agents_workflow_run_events_input_schema(): array { + $schema = agents_workflow_run_id_input_schema(); + $properties = is_array( $schema['properties'] ?? null ) ? $schema['properties'] : array(); + $properties['cursor'] = array( 'type' => 'string' ); + $properties['limit'] = array( 'type' => 'integer' ); + $schema['properties'] = $properties; + return $schema; +} + +/** @return array */ +function agents_workflow_run_control_output_schema( bool $include_cancelled = false ): array { + $properties = array( + 'run_id' => array( 'type' => 'string' ), + 'status' => array( + 'type' => 'string', + 'enum' => \AgentsAPI\AI\WP_Agent_Run_Control::statuses(), + ), + 'started_at' => array( 'type' => 'string' ), + 'updated_at' => array( 'type' => 'string' ), + 'workflow_id' => array( 'type' => 'string' ), + 'metadata' => array( 'type' => 'object' ), + ); + $required = array( 'run_id', 'status', 'started_at', 'updated_at', 'metadata' ); + if ( $include_cancelled ) { + $required[] = 'cancelled'; + $properties['cancelled'] = array( 'type' => 'boolean' ); + } + + return array( + 'type' => 'object', + 'required' => $required, + 'properties' => $properties, + ); +} + +/** @return array */ +function agents_workflow_run_events_output_schema(): array { + $schema = agents_workflow_run_control_output_schema(); + $required = is_array( $schema['required'] ?? null ) ? array_values( $schema['required'] ) : array(); + $properties = is_array( $schema['properties'] ?? null ) ? $schema['properties'] : array(); + $required[] = 'events'; + $required[] = 'cursor'; + $required[] = 'has_more'; + $properties['events'] = array( + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ); + $properties['cursor'] = array( 'type' => 'string' ); + $properties['has_more'] = array( 'type' => 'boolean' ); + $schema['required'] = $required; + $schema['properties'] = $properties; + return $schema; +} + +function agents_workflow_string( mixed $value ): string { + return is_scalar( $value ) ? trim( (string) $value ) : ''; +} + /** * Convenience helper for consumers: register a callable as the workflow * runtime handler. diff --git a/tests/canonical-run-lifecycle-smoke.php b/tests/canonical-run-lifecycle-smoke.php new file mode 100644 index 0000000..17de85f --- /dev/null +++ b/tests/canonical-run-lifecycle-smoke.php @@ -0,0 +1,133 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): array { return $this->data; } + } +} + +if ( ! function_exists( 'current_user_can' ) ) { + function current_user_can( string $capability ): bool { + unset( $capability ); + return true; + } +} + +agents_api_smoke_require_module(); + +final class Agents_API_Smoke_Run_Control_Store implements AgentsAPI\AI\WP_Agent_Run_Control_Store { + /** @var array>,queues:array>>,events:array>>}> */ + private array $states = array(); + + public function get_state( string $store_key ): array { + return $this->states[ $store_key ] ?? array( + 'runs' => array(), + 'queues' => array(), + 'events' => array(), + ); + } + + public function save_state( string $store_key, array $state ): void { + $this->states[ $store_key ] = array( + 'runs' => is_array( $state['runs'] ?? null ) ? $state['runs'] : array(), + 'queues' => is_array( $state['queues'] ?? null ) ? $state['queues'] : array(), + 'events' => is_array( $state['events'] ?? null ) ? $state['events'] : array(), + ); + } +} + +AgentsAPI\AI\WP_Agent_Run_Control::set_store( new Agents_API_Smoke_Run_Control_Store() ); + +AgentsAPI\AI\WP_Agent_Run_Control::start_run( 'smoke_store', 'run-1', array( 'metadata' => array( 'kind' => 'unit' ) ) ); +$cancelled = AgentsAPI\AI\WP_Agent_Run_Control::request_cancel( 'smoke_store', 'run-1' ); +$events = AgentsAPI\AI\WP_Agent_Run_Control::list_events( 'smoke_store', 'run-1' ); +agents_api_smoke_assert_equals( 'cancelling', $cancelled['status'] ?? null, 'generic run-control accepts cancellation', $failures, $passes ); +agents_api_smoke_assert_equals( true, count( $events['events'] ?? array() ) >= 2, 'generic run-control records lifecycle events', $failures, $passes ); +agents_api_smoke_assert_equals( 'cancel_requested', $events['events'][1]['type'] ?? null, 'generic run-control records cancellation event', $failures, $passes ); + +AgentsAPI\AI\WP_Agent_Run_Control::start_run( + AgentsAPI\AI\Workflows\WP_Agent_Workflow_Runner::RUN_CONTROL_STORE, + 'workflow-run-1', + array( 'workflow_id' => 'demo-workflow' ) +); +$workflow_status = AgentsAPI\AI\Workflows\agents_get_workflow_run( array( 'run_id' => 'workflow-run-1' ) ); +$workflow_cancel = AgentsAPI\AI\Workflows\agents_cancel_workflow_run( array( 'run_id' => 'workflow-run-1' ) ); +$workflow_events = AgentsAPI\AI\Workflows\agents_list_workflow_run_events( array( 'run_id' => 'workflow-run-1' ) ); +agents_api_smoke_assert_equals( 'demo-workflow', $workflow_status['workflow_id'] ?? null, 'workflow get-run reads shared run-control state', $failures, $passes ); +agents_api_smoke_assert_equals( true, $workflow_cancel['cancelled'] ?? null, 'workflow cancel-run marks cancellation requested', $failures, $passes ); +agents_api_smoke_assert_equals( 'cancel_requested', $workflow_events['events'][1]['type'] ?? null, 'workflow list-events exposes lifecycle events', $failures, $passes ); + +add_filter( + 'wp_agent_runtime_package_run_handler', + static function ( $handler, AgentsAPI\AI\WP_Agent_Runtime_Package_Run_Request $request, array $input ) { + unset( $handler, $request, $input ); + return static function ( AgentsAPI\AI\WP_Agent_Runtime_Package_Run_Request $request, array $input ): array { + unset( $request ); + return array( + 'run_id' => $input['run_id'], + 'status' => 'succeeded', + 'result' => array( 'ok' => true ), + ); + }; + }, + 10, + 3 +); +$runtime_result = AgentsAPI\AI\agents_runtime_package_run_dispatch( + array( + 'run_id' => 'runtime-run-1', + 'package' => array( 'slug' => 'portable-package' ), + 'workflow' => array( 'id' => 'demo' ), + ) +); +$runtime_status = AgentsAPI\AI\agents_get_runtime_package_run( array( 'run_id' => 'runtime-run-1' ) ); +$runtime_events = AgentsAPI\AI\agents_list_runtime_package_run_events( array( 'run_id' => 'runtime-run-1' ) ); +agents_api_smoke_assert_equals( true, $runtime_result['result']['ok'] ?? null, 'runtime package run returns handler result', $failures, $passes ); +agents_api_smoke_assert_equals( 'succeeded', $runtime_status['status'] ?? null, 'runtime package get-run reads shared run-control state', $failures, $passes ); +agents_api_smoke_assert_equals( true, count( $runtime_events['events'] ?? array() ) >= 2, 'runtime package list-events exposes lifecycle events', $failures, $passes ); + +$turns = 0; +$loop_id = 'loop-run-1'; +$loop = AgentsAPI\AI\WP_Agent_Conversation_Loop::run( + array( AgentsAPI\AI\WP_Agent_Message::text( 'user', 'hello' ) ), + static function ( array $messages, array $context ) use ( &$turns, $loop_id ): array { + unset( $messages, $context ); + ++$turns; + AgentsAPI\AI\WP_Agent_Chat_Run_Control::request_cancel( $loop_id ); + return array( + 'messages' => array( AgentsAPI\AI\WP_Agent_Message::text( 'assistant', 'working' ) ), + 'completed' => false, + ); + }, + array( + 'run_id' => $loop_id, + 'transcript_session_id' => 'loop-session-1', + 'max_turns' => 3, + 'should_continue' => static fn(): bool => true, + ) +); +agents_api_smoke_assert_equals( 1, $turns, 'conversation loop stops after provider-turn cancellation', $failures, $passes ); +agents_api_smoke_assert_equals( 'interrupted', $loop['status'] ?? null, 'conversation loop reports cancellation interruption', $failures, $passes ); + +agents_api_smoke_finish( 'canonical run lifecycle', $failures, $passes );