Skip to content
Open
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
34 changes: 31 additions & 3 deletions src/Abilities/class-wp-agent-ability-lifecycle-bridge.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,27 @@
* adopts them slice by slice (see #94).
*
* Wired today:
* - `wp_ability_invoked` -> `agents_api_ability_invoked` action. Fires at the
* top of `execute()` for every call, before any processing, so observers see
* invocations that never reach a result (validation failure, permission
* denial, pre-execute short-circuit).
* - `wp_ability_execute_result` -> `agents_api_ability_executed` action.
* Telemetry without each ability author opting in.
* - `wp_pre_execute_ability` -> decision-driven approval gate. Hosts decide
* via {@see self::FILTER_PRE_EXECUTE_DECISION}; the bridge handles the
* sentinel, minting, staging, and `approval_required` envelope.
*
* The execute-result observer returns the filter input unchanged. The
* pre-execute gate short-circuits with an `approval_required` envelope when
* the host signals approval is needed, and passes through otherwise.
* The invoked and execute-result observers return the filter/value input
* unchanged. The pre-execute gate short-circuits with an `approval_required`
* envelope when the host signals approval is needed, and passes through
* otherwise.
*
* On WordPress < 7.1 the underlying filters are never applied, so registered
* handlers stay idle.
*/
class WP_Agent_Ability_Lifecycle_Bridge {

public const ACTION_ABILITY_INVOKED = 'agents_api_ability_invoked';
public const ACTION_ABILITY_EXECUTED = 'agents_api_ability_executed';
public const FILTER_PRE_EXECUTE_DECISION = 'agents_api_ability_pre_execute_decision';
public const FILTER_PENDING_ACTION_STORE = 'wp_agent_pending_action_store';
Expand All @@ -57,10 +63,32 @@ public static function register(): void {
return;
}

add_action( 'wp_ability_invoked', array( __CLASS__, 'observe_invoked' ), 10, 3 );
add_filter( 'wp_ability_execute_result', array( __CLASS__, 'observe_execute_result' ), 10, 4 );
add_filter( 'wp_pre_execute_ability', array( __CLASS__, 'gate_pre_execute' ), 10, 4 );
}

/**
* Observer for the `wp_ability_invoked` action.
*
* Re-emits `agents_api_ability_invoked` at the entry point of every ability
* call. Unlike {@see self::ACTION_ABILITY_EXECUTED}, which only fires on the
* registered execute_callback's success path, this fires for every call
* regardless of outcome, so observers can record invocations that fail input
* validation, fail the permission check, or get short-circuited before the
* callback runs. The input is the raw value passed to `execute()`, before
* normalization.
*
* @param string $ability_name Ability name.
* @param mixed $input Raw input passed to execute(), before normalization.
* @param object $ability `WP_Ability` instance.
*/
public static function observe_invoked( string $ability_name, $input, $ability ): void {
if ( function_exists( 'do_action' ) ) {
do_action( self::ACTION_ABILITY_INVOKED, $ability_name, $input, $ability );
}
}

/**
* Observer for the `wp_ability_execute_result` filter.
*
Expand Down
32 changes: 32 additions & 0 deletions tests/ability-lifecycle-bridge-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,36 @@ static function ( string $ability_name, $result, $input, $ability ) use ( &$obse
agents_api_smoke_assert_equals( 'test/one', $observed[0]['ability_name'] ?? null, 'first observation carries ability name', $failures, $passes );
agents_api_smoke_assert_equals( 'test/three', $observed[2]['ability_name'] ?? null, 'third observation carries ability name', $failures, $passes );

echo "\n[4] Invoked observer re-emits the entry-point action with raw input:\n";
$invoked = array();
add_action(
WP_Agent_Ability_Lifecycle_Bridge::ACTION_ABILITY_INVOKED,
static function ( string $ability_name, $input, $ability ) use ( &$invoked ): void {
$invoked[] = array(
'ability_name' => $ability_name,
'input' => $input,
'ability' => $ability,
);
},
10,
3
);

$raw_input = array( 'q' => 'raw' );
do_action( 'wp_ability_invoked', 'test/echo', $raw_input, $ability_stub );

agents_api_smoke_assert_equals( 1, count( $invoked ), 'invoked observer fired once', $failures, $passes );
agents_api_smoke_assert_equals( 'test/echo', $invoked[0]['ability_name'] ?? null, 'invoked observer received ability name', $failures, $passes );
agents_api_smoke_assert_equals( $raw_input, $invoked[0]['input'] ?? null, 'invoked observer received raw input', $failures, $passes );
agents_api_smoke_assert_equals( true, ( $invoked[0]['ability'] ?? null ) === $ability_stub, 'invoked observer received the ability instance', $failures, $passes );

echo "\n[5] Invoked fires for calls that never reach a result:\n";
$invoked = array();
// An invocation that fails validation/permission still fires wp_ability_invoked
// at the top of execute(), but never reaches wp_ability_execute_result.
do_action( 'wp_ability_invoked', 'test/denied', array(), $ability_stub );

agents_api_smoke_assert_equals( 1, count( $invoked ), 'invoked observer fires even when no result follows', $failures, $passes );
agents_api_smoke_assert_equals( 'test/denied', $invoked[0]['ability_name'] ?? null, 'invoked observer records the short-circuited ability', $failures, $passes );

agents_api_smoke_finish( 'ability lifecycle bridge', $failures, $passes );
Loading