diff --git a/agents-api.php b/agents-api.php index 715e428..9a1bde2 100644 --- a/agents-api.php +++ b/agents-api.php @@ -66,6 +66,7 @@ require_once AGENTS_API_PATH . 'src/Approvals/class-wp-agent-approval-decision.php'; require_once AGENTS_API_PATH . 'src/Approvals/class-wp-agent-pending-action-handler.php'; require_once AGENTS_API_PATH . 'src/Approvals/class-wp-agent-pending-action-resolver.php'; +require_once AGENTS_API_PATH . 'src/Approvals/register-pending-action-abilities.php'; require_once AGENTS_API_PATH . 'src/Consent/class-wp-agent-consent-operation.php'; require_once AGENTS_API_PATH . 'src/Consent/class-wp-agent-consent-decision.php'; require_once AGENTS_API_PATH . 'src/Consent/class-wp-agent-consent-policy.php'; diff --git a/composer.json b/composer.json index e3827ce..f0fea4f 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "php tests/tool-runtime-smoke.php", "php tests/pending-action-store-contract-smoke.php", "php tests/approval-resolver-contract-smoke.php", + "php tests/pending-action-abilities-smoke.php", "php tests/identity-smoke.php", "php tests/memory-metadata-contract-smoke.php", "php tests/approval-action-value-shape-smoke.php", diff --git a/src/Approvals/register-pending-action-abilities.php b/src/Approvals/register-pending-action-abilities.php new file mode 100644 index 0000000..853e428 --- /dev/null +++ b/src/Approvals/register-pending-action-abilities.php @@ -0,0 +1,362 @@ + 'Agents API', + 'description' => 'Cross-cutting abilities provided by the Agents API substrate.', + ) + ); + } +); + +add_action( + 'wp_abilities_api_init', + static function (): void { + $abilities = array( + AGENTS_LIST_PENDING_ACTIONS_ABILITY => array( + 'label' => 'List Pending Actions', + 'description' => 'List pending action records from the host-provided pending action store.', + 'input_schema' => agents_pending_action_filters_input_schema(), + 'output_schema' => agents_list_pending_actions_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_list_pending_actions', + 'idempotent' => true, + ), + AGENTS_SUMMARY_PENDING_ACTIONS_ABILITY => array( + 'label' => 'Summarize Pending Actions', + 'description' => 'Summarize pending action records from the host-provided pending action store.', + 'input_schema' => agents_pending_action_filters_input_schema(), + 'output_schema' => array( 'type' => 'object' ), + 'execute_callback' => __NAMESPACE__ . '\\agents_summary_pending_actions', + 'idempotent' => true, + ), + AGENTS_GET_PENDING_ACTION_ABILITY => array( + 'label' => 'Get Pending Action', + 'description' => 'Fetch one pending action record from the host-provided pending action store.', + 'input_schema' => agents_get_pending_action_input_schema(), + 'output_schema' => agents_get_pending_action_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_get_pending_action', + 'idempotent' => true, + ), + AGENTS_RESOLVE_PENDING_ACTION_ABILITY => array( + 'label' => 'Resolve Pending Action', + 'description' => 'Accept or reject a pending action through the host-provided pending action resolver.', + 'input_schema' => agents_resolve_pending_action_input_schema(), + 'output_schema' => agents_resolve_pending_action_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_resolve_pending_action', + 'idempotent' => false, + ), + ); + + foreach ( $abilities as $name => $ability ) { + if ( wp_has_ability( $name ) ) { + continue; + } + + wp_register_ability( + $name, + array( + 'label' => $ability['label'], + 'description' => $ability['description'], + 'category' => 'agents-api', + 'input_schema' => $ability['input_schema'], + 'output_schema' => $ability['output_schema'], + 'execute_callback' => $ability['execute_callback'], + 'permission_callback' => __NAMESPACE__ . '\\agents_pending_action_permission', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'destructive' => ! $ability['idempotent'], + 'idempotent' => $ability['idempotent'], + ), + ), + ) + ); + } + } +); + +/** + * Discover the host-provided pending action store. + * + * @param array $input Ability input. + * @return WP_Agent_Pending_Action_Store|null + */ +function agents_get_pending_action_store( array $input = array() ): ?WP_Agent_Pending_Action_Store { + $store = apply_filters( 'wp_agent_pending_action_store', null, $input ); + + return $store instanceof WP_Agent_Pending_Action_Store ? $store : null; +} + +/** + * Discover the host-provided pending action resolver. + * + * @param array $input Ability input. + * @return WP_Agent_Pending_Action_Resolver|null + */ +function agents_get_pending_action_resolver( array $input = array() ): ?WP_Agent_Pending_Action_Resolver { + $resolver = apply_filters( 'wp_agent_pending_action_resolver', null, $input ); + + return $resolver instanceof WP_Agent_Pending_Action_Resolver ? $resolver : null; +} + +/** + * List pending actions through the discovered store. + * + * @param array $input Ability input. + * @return array|\WP_Error + */ +function agents_list_pending_actions( array $input ) { + $store = agents_get_pending_action_store( $input ); + if ( null === $store ) { + return agents_pending_action_no_store_error(); + } + + $actions = array(); + foreach ( $store->list( agents_pending_action_filters( $input ) ) as $action ) { + $actions[] = $action->to_array(); + } + + return array( 'actions' => $actions ); +} + +/** + * Summarize pending actions through the discovered store. + * + * @param array $input Ability input. + * @return array|\WP_Error + */ +function agents_summary_pending_actions( array $input ) { + $store = agents_get_pending_action_store( $input ); + if ( null === $store ) { + return agents_pending_action_no_store_error(); + } + + return $store->summary( agents_pending_action_filters( $input ) ); +} + +/** + * Get one pending action through the discovered store. + * + * @param array $input Ability input. + * @return array|\WP_Error + */ +function agents_get_pending_action( array $input ) { + $action_id = trim( (string) ( $input['action_id'] ?? '' ) ); + if ( '' === $action_id ) { + return new \WP_Error( 'agents_pending_action_missing_action_id', 'action_id is required.' ); + } + + $store = agents_get_pending_action_store( $input ); + if ( null === $store ) { + return agents_pending_action_no_store_error(); + } + + $action = $store->get( $action_id, (bool) ( $input['include_resolved'] ?? false ) ); + + return array( 'action' => null === $action ? null : $action->to_array() ); +} + +/** + * Resolve one pending action through the discovered resolver. + * + * @param array $input Ability input. + * @return array|\WP_Error + */ +function agents_resolve_pending_action( array $input ) { + $action_id = trim( (string) ( $input['action_id'] ?? '' ) ); + $resolver_id = trim( (string) ( $input['resolver'] ?? '' ) ); + if ( '' === $action_id ) { + return new \WP_Error( 'agents_pending_action_missing_action_id', 'action_id is required.' ); + } + if ( '' === $resolver_id ) { + return new \WP_Error( 'agents_pending_action_missing_resolver', 'resolver is required.' ); + } + + try { + $decision = WP_Agent_Approval_Decision::from_string( (string) ( $input['decision'] ?? '' ) ); + } catch ( \InvalidArgumentException $error ) { + return new \WP_Error( 'agents_pending_action_invalid_decision', $error->getMessage() ); + } + + $resolver = agents_get_pending_action_resolver( $input ); + if ( null === $resolver ) { + return new \WP_Error( + 'agents_pending_action_no_resolver', + 'No pending action resolver is registered. Add a WP_Agent_Pending_Action_Resolver to the wp_agent_pending_action_resolver filter.' + ); + } + + $result = $resolver->resolve_pending_action( + $action_id, + $decision, + $resolver_id, + (array) ( $input['payload'] ?? array() ), + (array) ( $input['context'] ?? array() ) + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return array( + 'action_id' => $action_id, + 'decision' => $decision->value(), + 'result' => $result, + ); +} + +/** + * Permission gate for pending action abilities. + * + * @param array $input Ability input. + * @return bool + */ +function agents_pending_action_permission( array $input ): bool { + return (bool) apply_filters( + 'agents_pending_action_permission', + current_user_can( 'manage_options' ), + $input + ); +} + +/** + * Extract store filters from ability input. + * + * @param array $input Ability input. + * @return array + */ +function agents_pending_action_filters( array $input ): array { + return (array) ( $input['filters'] ?? array() ); +} + +/** + * Standard no-store error. + * + * @return \WP_Error + */ +function agents_pending_action_no_store_error(): \WP_Error { + return new \WP_Error( + 'agents_pending_action_no_store', + 'No pending action store is registered. Add a WP_Agent_Pending_Action_Store to the wp_agent_pending_action_store filter.' + ); +} + +/** @return array */ +function agents_pending_action_filters_input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'filters' => array( + 'type' => 'object', + 'description' => 'Implementation-defined pending action store filters such as status, kind, workspace, agent, creator, limit, or offset.', + 'default' => array(), + ), + ), + ); +} + +/** @return array */ +function agents_get_pending_action_input_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'action_id' ), + 'properties' => array( + 'action_id' => array( 'type' => 'string' ), + 'include_resolved' => array( + 'type' => 'boolean', + 'description' => 'Whether terminal audit records may be returned.', + 'default' => false, + ), + ), + ); +} + +/** @return array */ +function agents_resolve_pending_action_input_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'action_id', 'decision', 'resolver' ), + 'properties' => array( + 'action_id' => array( 'type' => 'string' ), + 'decision' => array( + 'type' => 'string', + 'enum' => array( WP_Agent_Approval_Decision::ACCEPTED, WP_Agent_Approval_Decision::REJECTED ), + ), + 'resolver' => array( + 'type' => 'string', + 'description' => 'Resolver identifier, such as a user, token, or service actor.', + ), + 'payload' => array( + 'type' => 'object', + 'description' => 'Decision payload forwarded to the resolver.', + 'default' => array(), + ), + 'context' => array( + 'type' => 'object', + 'description' => 'Caller context forwarded to the resolver.', + 'default' => array(), + ), + ), + ); +} + +/** @return array */ +function agents_list_pending_actions_output_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'actions' ), + 'properties' => array( + 'actions' => array( + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ), + ), + ); +} + +/** @return array */ +function agents_get_pending_action_output_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'action' ), + 'properties' => array( + 'action' => array( 'type' => array( 'object', 'null' ) ), + ), + ); +} + +/** @return array */ +function agents_resolve_pending_action_output_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'action_id', 'decision', 'result' ), + 'properties' => array( + 'action_id' => array( 'type' => 'string' ), + 'decision' => array( 'type' => 'string' ), + 'result' => array(), + ), + ); +} diff --git a/tests/agents-api-smoke-helpers.php b/tests/agents-api-smoke-helpers.php index c8ea6fe..30f14a7 100644 --- a/tests/agents-api-smoke-helpers.php +++ b/tests/agents-api-smoke-helpers.php @@ -180,7 +180,7 @@ function wp_set_object_terms( int $post_id, $terms, string $taxonomy ): void { } function is_wp_error( $value ): bool { - return false; + return class_exists( 'WP_Error' ) && $value instanceof WP_Error; } function agents_api_smoke_assert_equals( $expected, $actual, string $name, array &$failures, int &$passes ): void { diff --git a/tests/pending-action-abilities-smoke.php b/tests/pending-action-abilities-smoke.php new file mode 100644 index 0000000..5062c20 --- /dev/null +++ b/tests/pending-action-abilities-smoke.php @@ -0,0 +1,144 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data() { return $this->data; } +} +function current_user_can( string $cap ): bool { unset( $cap ); return $GLOBALS['__can'] ?? false; } + +require_once __DIR__ . '/../src/Workspace/class-wp-agent-workspace-scope.php'; +require_once __DIR__ . '/../src/Approvals/class-wp-agent-pending-action-store.php'; +require_once __DIR__ . '/../src/Approvals/class-wp-agent-pending-action-status.php'; +require_once __DIR__ . '/../src/Approvals/class-wp-agent-pending-action.php'; +require_once __DIR__ . '/../src/Approvals/class-wp-agent-approval-decision.php'; +require_once __DIR__ . '/../src/Approvals/class-wp-agent-pending-action-resolver.php'; +require_once __DIR__ . '/../src/Approvals/register-pending-action-abilities.php'; + +use AgentsAPI\AI\Approvals\WP_Agent_Approval_Decision; +use AgentsAPI\AI\Approvals\WP_Agent_Pending_Action; +use AgentsAPI\AI\Approvals\WP_Agent_Pending_Action_Resolver; +use AgentsAPI\AI\Approvals\WP_Agent_Pending_Action_Store; +use function AgentsAPI\AI\Approvals\agents_get_pending_action; +use function AgentsAPI\AI\Approvals\agents_get_pending_action_resolver; +use function AgentsAPI\AI\Approvals\agents_get_pending_action_store; +use function AgentsAPI\AI\Approvals\agents_list_pending_actions; +use function AgentsAPI\AI\Approvals\agents_pending_action_permission; +use function AgentsAPI\AI\Approvals\agents_resolve_pending_action; +use function AgentsAPI\AI\Approvals\agents_summary_pending_actions; + +$action = WP_Agent_Pending_Action::from_array( + array( + 'action_id' => 'pa_123', + 'kind' => 'demo/action', + 'summary' => 'Approve demo action', + 'preview' => array( 'message' => 'hello' ), + 'apply_input' => array( 'message' => 'hello' ), + 'created_at' => '2026-05-12T00:00:00Z', + ) +); + +$store = new class( $action ) implements WP_Agent_Pending_Action_Store { + public array $last_filters = array(); + + public function __construct( private WP_Agent_Pending_Action $action ) {} + public function store( WP_Agent_Pending_Action $action ): bool { unset( $action ); return true; } + public function get( string $action_id, bool $include_resolved = false ): ?WP_Agent_Pending_Action { + return 'pa_123' === $action_id && ! $include_resolved ? $this->action : null; + } + public function list( array $filters = array() ): array { + $this->last_filters = $filters; + return array( $this->action ); + } + public function summary( array $filters = array() ): array { + $this->last_filters = $filters; + return array( 'total' => 1, 'by_status' => array( 'pending' => 1 ) ); + } + public function record_resolution( string $action_id, WP_Agent_Approval_Decision $decision, string $resolver, $result = null, ?string $error = null, array $metadata = array() ): bool { + unset( $action_id, $decision, $resolver, $result, $error, $metadata ); + return true; + } + public function expire( ?string $before = null ): int { unset( $before ); return 0; } + public function delete( string $action_id ): bool { unset( $action_id ); return true; } +}; + +$resolver = new class() implements WP_Agent_Pending_Action_Resolver { + public array $calls = array(); + + public function resolve_pending_action( string $pending_action_id, WP_Agent_Approval_Decision $decision, string $resolver, array $payload = array(), array $context = array() ): mixed { + $this->calls[] = compact( 'pending_action_id', 'decision', 'resolver', 'payload', 'context' ); + return array( 'ok' => true ); + } +}; + +$GLOBALS['__can'] = false; +agents_api_smoke_assert_equals( false, agents_pending_action_permission( array() ), 'permission defaults to manage_options denial', $failures, $passes ); +$GLOBALS['__can'] = true; +agents_api_smoke_assert_equals( true, agents_pending_action_permission( array() ), 'permission allows manage_options', $failures, $passes ); + +agents_api_smoke_assert_equals( null, agents_get_pending_action_store(), 'store discovery returns null without host filter', $failures, $passes ); +add_filter( 'wp_agent_pending_action_store', static fn() => $store ); +add_filter( 'wp_agent_pending_action_resolver', static fn() => $resolver ); + +agents_api_smoke_assert_equals( true, agents_get_pending_action_store() instanceof WP_Agent_Pending_Action_Store, 'store discovery uses filter', $failures, $passes ); +agents_api_smoke_assert_equals( true, agents_get_pending_action_resolver() instanceof WP_Agent_Pending_Action_Resolver, 'resolver discovery uses filter', $failures, $passes ); + +$listed = agents_list_pending_actions( array( 'filters' => array( 'status' => 'pending' ) ) ); +agents_api_smoke_assert_equals( 'pa_123', $listed['actions'][0]['action_id'] ?? '', 'list returns pending action arrays', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'status' => 'pending' ), $store->last_filters, 'list forwards filters', $failures, $passes ); + +$summary = agents_summary_pending_actions( array( 'filters' => array( 'kind' => 'demo/action' ) ) ); +agents_api_smoke_assert_equals( 1, $summary['total'] ?? 0, 'summary returns store summary', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'kind' => 'demo/action' ), $store->last_filters, 'summary forwards filters', $failures, $passes ); + +$found = agents_get_pending_action( array( 'action_id' => 'pa_123' ) ); +agents_api_smoke_assert_equals( 'Approve demo action', $found['action']['summary'] ?? '', 'get returns pending action array', $failures, $passes ); + +$missing = agents_get_pending_action( array( 'action_id' => 'missing' ) ); +agents_api_smoke_assert_equals( true, array_key_exists( 'action', $missing ) && null === $missing['action'], 'get returns null for missing action', $failures, $passes ); + +$resolved = agents_resolve_pending_action( + array( + 'action_id' => 'pa_123', + 'decision' => 'accepted', + 'resolver' => 'user:1', + 'payload' => array( 'note' => 'ship it' ), + 'context' => array( 'surface' => 'chat' ), + ) +); +agents_api_smoke_assert_equals( 'accepted', $resolved['decision'] ?? '', 'resolve returns normalized decision', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'ok' => true ), $resolved['result'] ?? null, 'resolve returns resolver result', $failures, $passes ); +agents_api_smoke_assert_equals( 'user:1', $resolver->calls[0]['resolver'] ?? '', 'resolve forwards resolver id', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'surface' => 'chat' ), $resolver->calls[0]['context'] ?? array(), 'resolve forwards context', $failures, $passes ); + +$invalid_decision = agents_resolve_pending_action( array( 'action_id' => 'pa_123', 'decision' => 'approved', 'resolver' => 'user:1' ) ); +agents_api_smoke_assert_equals( true, $invalid_decision instanceof WP_Error, 'invalid decision returns WP_Error', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_pending_action_invalid_decision', $invalid_decision instanceof WP_Error ? $invalid_decision->get_error_code() : '', 'invalid decision error code', $failures, $passes ); + +$GLOBALS['__agents_api_smoke_actions'] = array(); +$no_store = agents_list_pending_actions( array() ); +agents_api_smoke_assert_equals( true, $no_store instanceof WP_Error, 'missing store returns WP_Error', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_pending_action_no_store', $no_store instanceof WP_Error ? $no_store->get_error_code() : '', 'missing store error code', $failures, $passes ); + +$no_resolver = agents_resolve_pending_action( array( 'action_id' => 'pa_123', 'decision' => 'accepted', 'resolver' => 'user:1' ) ); +agents_api_smoke_assert_equals( true, $no_resolver instanceof WP_Error, 'missing resolver returns WP_Error', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_pending_action_no_resolver', $no_resolver instanceof WP_Error ? $no_resolver->get_error_code() : '', 'missing resolver error code', $failures, $passes ); + +agents_api_smoke_finish( 'pending action abilities', $failures, $passes );