diff --git a/agents-api.php b/agents-api.php index a578412..94ccde5 100644 --- a/agents-api.php +++ b/agents-api.php @@ -218,6 +218,8 @@ require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-store.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-lifecycle.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-run-recorder.php'; +require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-run-context.php'; +require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-step-executor.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-runner.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-registry.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-action-scheduler-bridge.php'; diff --git a/src/Workflows/class-wp-agent-workflow-run-context.php b/src/Workflows/class-wp-agent-workflow-run-context.php new file mode 100644 index 0000000..445b225 --- /dev/null +++ b/src/Workflows/class-wp-agent-workflow-run-context.php @@ -0,0 +1,73 @@ + $data Context data exposed to binding resolution and step handlers. + */ + public function __construct( private array $data ) {} + + /** + * Build the initial context for a workflow run. + * + * @param array $inputs Caller-supplied workflow inputs. + */ + public static function from_inputs( array $inputs ): self { + return new self( + array( + 'inputs' => $inputs, + 'steps' => array(), + 'vars' => array(), + ) + ); + } + + /** + * Return the context as the array shape expected by bindings and handlers. + * + * @return array + */ + public function to_array(): array { + return $this->data; + } + + /** + * Return a copy of the context with scoped iteration variables merged in. + * + * @param array $vars Variables to expose under `${vars.*}`. + */ + public function with_vars( array $vars ): self { + $data = $this->data; + $data['vars'] = array_merge( (array) ( $data['vars'] ?? array() ), $vars ); + if ( ! isset( $data['steps'] ) || ! is_array( $data['steps'] ) ) { + $data['steps'] = array(); + } + + return new self( $data ); + } + + /** + * Record a successful step output for subsequent `${steps.*}` bindings. + * + * @param array $output Normalized step output. + */ + public function set_step_output( string $step_id, array $output ): void { + if ( '' === $step_id ) { + return; + } + if ( ! isset( $this->data['steps'] ) || ! is_array( $this->data['steps'] ) ) { + $this->data['steps'] = array(); + } + + $this->data['steps'][ $step_id ] = array( 'output' => $output ); + } +} diff --git a/src/Workflows/class-wp-agent-workflow-runner.php b/src/Workflows/class-wp-agent-workflow-runner.php index 6b0f112..6fa13a0 100644 --- a/src/Workflows/class-wp-agent-workflow-runner.php +++ b/src/Workflows/class-wp-agent-workflow-runner.php @@ -162,12 +162,8 @@ public function run( WP_Agent_Workflow_Spec $spec, array $inputs = array(), arra return $terminal; } - $context = array( - 'inputs' => $inputs, - // Step outputs accumulate here as the run progresses, keyed by step id. - 'steps' => array(), - 'vars' => array(), - ); + $context = WP_Agent_Workflow_Run_Context::from_inputs( $inputs ); + $executor = new WP_Agent_Workflow_Step_Executor( $this->step_handlers ); $step_records = array(); $continue_on_error = ! empty( $options['continue_on_error'] ); @@ -175,55 +171,10 @@ public function run( WP_Agent_Workflow_Spec $spec, array $inputs = array(), arra $failure_error = array(); foreach ( $spec->get_steps() as $step ) { - $step_id = self::string_value( $step['id'] ?? null ); - $type = self::string_value( $step['type'] ?? null ); - $start_ts = time(); - $resolved = 'foreach' === $type - ? self::expand_foreach_outer_step( $step, $context ) - : WP_Agent_Workflow_Bindings::expand( $step, $context ); - $record = array( - 'id' => $step_id, - 'type' => $type, - 'status' => WP_Agent_Workflow_Run_Result::STATUS_RUNNING, - 'output' => null, - 'started_at' => $start_ts, - 'ended_at' => 0, - ); - - $handler = $this->step_handlers[ $type ] ?? null; - if ( ! is_callable( $handler ) ) { - $record['status'] = WP_Agent_Workflow_Run_Result::STATUS_SKIPPED; - $record['ended_at'] = time(); - $record['error'] = array( - 'code' => 'no_step_handler', - 'message' => sprintf( 'no handler registered for step type `%s`', $type ), - ); - $step_records[] = $record; - - $failed = true; - $failure_error = $record['error']; - $result = $result->with( array( 'steps' => $step_records ) ); - if ( $this->recorder ) { - $this->recorder->update( $result ); - } - if ( ! $continue_on_error ) { - break; - } - continue; - } - - $step_output = call_user_func( $handler, $resolved, $context ); - - if ( is_wp_error( $step_output ) ) { - $record['status'] = WP_Agent_Workflow_Run_Result::STATUS_FAILED; - $record['ended_at'] = time(); - $record['error'] = array( - 'code' => $step_output->get_error_code(), - 'message' => $step_output->get_error_message(), - 'data' => $step_output->get_error_data(), - ); - $step_records[] = $record; + $record = $executor->execute( $step, $context ); + $step_records[] = $record; + if ( WP_Agent_Workflow_Run_Result::STATUS_SUCCEEDED !== $record['status'] ) { $failed = true; $failure_error = $record['error']; $result = $result->with( array( 'steps' => $step_records ) ); @@ -236,13 +187,6 @@ public function run( WP_Agent_Workflow_Spec $spec, array $inputs = array(), arra continue; } - $record['status'] = WP_Agent_Workflow_Run_Result::STATUS_SUCCEEDED; - $record['output'] = is_array( $step_output ) ? $step_output : array( 'value' => $step_output ); - $record['ended_at'] = time(); - $step_records[] = $record; - - $context['steps'][ $step_id ] = array( 'output' => $record['output'] ); - $result = $result->with( array( 'steps' => $step_records ) ); if ( $this->recorder ) { $this->recorder->update( $result ); @@ -260,7 +204,7 @@ public function run( WP_Agent_Workflow_Spec $spec, array $inputs = array(), arra 'steps' => array(), ); foreach ( $step_records as $rec ) { - $final_output['steps'][ $rec['id'] ] = $rec['output']; + $final_output['steps'][ self::string_value( $rec['id'] ?? null ) ] = $rec['output'] ?? null; } $last = end( $step_records ); if ( false !== $last && WP_Agent_Workflow_Run_Result::STATUS_SUCCEEDED === $last['status'] ) { @@ -282,29 +226,6 @@ public function run( WP_Agent_Workflow_Spec $spec, array $inputs = array(), arra return $result; } - /** - * Expand a foreach step's outer fields while preserving its nested step - * templates for each iteration's scoped variables. - * - * @since 0.107.0 - * - * @param array $step - * @param array $context - * @return array - */ - private static function expand_foreach_outer_step( array $step, array $context ): array { - $nested = $step['steps'] ?? array(); - unset( $step['steps'] ); - - $expanded = WP_Agent_Workflow_Bindings::expand( $step, $context ); - if ( ! is_array( $expanded ) ) { - $expanded = array(); - } - $expanded['steps'] = $nested; - - return $expanded; - } - /** * Validate inputs against the spec's declared input schemas. * @@ -369,25 +290,18 @@ public static function default_ability_handler( array $step, array $context ) { */ public static function default_agent_handler( array $step, array $context ) { unset( $context ); - if ( ! function_exists( 'wp_get_ability' ) ) { - return new \WP_Error( - 'abilities_api_missing', - 'Abilities API is not loaded; cannot dispatch agent step.' - ); - } - $ability = wp_get_ability( 'agents/chat' ); - if ( null === $ability ) { - return new \WP_Error( - 'agents_chat_missing', - 'agents/chat ability is not registered.' - ); - } $input = array( 'agent' => self::string_value( $step['agent'] ?? null ), 'message' => self::string_value( $step['message'] ?? null ), 'session_id' => $step['session_id'] ?? null, ); - $result = $ability->execute( $input ); + $result = WP_Agent_Ability_Dispatcher::dispatch( 'agents/chat', $input ); + if ( is_wp_error( $result ) && 'ability_not_found' === $result->get_error_code() ) { + return new \WP_Error( + 'agents_chat_missing', + 'agents/chat ability is not registered.' + ); + } if ( is_wp_error( $result ) ) { return $result; } @@ -426,30 +340,19 @@ public static function default_foreach_handler( array $step, array $context ) { $as = '' !== $as_value ? $as_value : 'item'; $index_as = '' !== $index_as_value ? $index_as_value : 'index'; $continue_on_error = ! empty( $step['continue_on_error'] ); - $handlers = (array) apply_filters( - 'wp_agent_workflow_step_handlers', - array( - 'ability' => array( __CLASS__, 'default_ability_handler' ), - 'agent' => array( __CLASS__, 'default_agent_handler' ), - 'foreach' => array( __CLASS__, 'default_foreach_handler' ), - ) - ); + $handlers = self::default_step_handlers(); + $executor = new WP_Agent_Workflow_Step_Executor( $handlers ); $iterations = array(); foreach ( array_values( $items ) as $index => $item ) { - $iteration_context = $context; - if ( ! isset( $iteration_context['steps'] ) || ! is_array( $iteration_context['steps'] ) ) { - $iteration_context['steps'] = array(); - } - $iteration_context['vars'] = array_merge( - (array) ( $context['vars'] ?? array() ), + $iteration_context = ( new WP_Agent_Workflow_Run_Context( $context ) )->with_vars( array( $as => $item, $index_as => $index, ) ); - $step_outputs = array(); - $last_output = null; + $step_outputs = array(); + $last_output = null; foreach ( $steps as $nested_step ) { if ( ! is_array( $nested_step ) ) { @@ -479,28 +382,25 @@ public static function default_foreach_handler( array $step, array $context ) { continue; } - $resolved = 'foreach' === $type - ? self::expand_foreach_outer_step( $nested_step, $iteration_context ) - : WP_Agent_Workflow_Bindings::expand( $nested_step, $iteration_context ); - $nested_output = call_user_func( $handler, $resolved, $iteration_context ); + $nested_record = $executor->execute( $nested_step, $iteration_context ); - if ( is_wp_error( $nested_output ) ) { + if ( WP_Agent_Workflow_Run_Result::STATUS_SUCCEEDED !== $nested_record['status'] ) { + $error = is_array( $nested_record['error'] ?? null ) ? $nested_record['error'] : array(); if ( ! $continue_on_error ) { - return $nested_output; + return new \WP_Error( + self::string_value( $error['code'] ?? 'workflow_foreach_step_failed' ), + self::string_value( $error['message'] ?? 'foreach nested step failed.' ), + $error['data'] ?? null + ); } $last_output = array( - 'error' => array( - 'code' => $nested_output->get_error_code(), - 'message' => $nested_output->get_error_message(), - 'data' => $nested_output->get_error_data(), - ), + 'error' => $error, ); } else { - $last_output = is_array( $nested_output ) ? $nested_output : array( 'value' => $nested_output ); + $last_output = $nested_record['output']; } - $step_outputs[ $nested_id ] = $last_output; - $iteration_context['steps'][ $nested_id ] = array( 'output' => $last_output ); + $step_outputs[ $nested_id ] = $last_output; } $iterations[] = array( @@ -517,6 +417,25 @@ public static function default_foreach_handler( array $step, array $context ) { ); } + /** + * Return the filtered default handler map for nested step execution. + * + * @return array + */ + private static function default_step_handlers(): array { + /** @var array $handlers */ + $handlers = (array) apply_filters( + 'wp_agent_workflow_step_handlers', + array( + 'ability' => array( __CLASS__, 'default_ability_handler' ), + 'agent' => array( __CLASS__, 'default_agent_handler' ), + 'foreach' => array( __CLASS__, 'default_foreach_handler' ), + ) + ); + + return $handlers; + } + /** * Generate a run id when the caller didn't supply one. Prefers the * WordPress UUID helper when available, falls back to a uniqid-based diff --git a/src/Workflows/class-wp-agent-workflow-step-executor.php b/src/Workflows/class-wp-agent-workflow-step-executor.php new file mode 100644 index 0000000..e9fa0b3 --- /dev/null +++ b/src/Workflows/class-wp-agent-workflow-step-executor.php @@ -0,0 +1,109 @@ + $handlers Step type handler candidates. + */ + public function __construct( private array $handlers ) {} + + /** + * Resolve, dispatch, normalize, and record one step. + * + * @param array $step Raw workflow step. + * @param WP_Agent_Workflow_Run_Context $context Mutable run context. + * @return array Step record. + */ + public function execute( array $step, WP_Agent_Workflow_Run_Context $context ): array { + $step_id = self::string_value( $step['id'] ?? null ); + $type = self::string_value( $step['type'] ?? null ); + $start_ts = time(); + $record = array( + 'id' => $step_id, + 'type' => $type, + 'status' => WP_Agent_Workflow_Run_Result::STATUS_RUNNING, + 'output' => null, + 'started_at' => $start_ts, + 'ended_at' => 0, + ); + + $handler = $this->handlers[ $type ] ?? null; + if ( ! is_callable( $handler ) ) { + $record['status'] = WP_Agent_Workflow_Run_Result::STATUS_SKIPPED; + $record['ended_at'] = time(); + $record['error'] = array( + 'code' => 'no_step_handler', + 'message' => sprintf( 'no handler registered for step type `%s`', $type ), + ); + + return $record; + } + + $context_array = $context->to_array(); + $resolved = 'foreach' === $type + ? self::expand_foreach_outer_step( $step, $context_array ) + : WP_Agent_Workflow_Bindings::expand( $step, $context_array ); + $step_output = call_user_func( $handler, $resolved, $context_array ); + + if ( is_wp_error( $step_output ) ) { + $record['status'] = WP_Agent_Workflow_Run_Result::STATUS_FAILED; + $record['ended_at'] = time(); + $record['error'] = array( + 'code' => $step_output->get_error_code(), + 'message' => $step_output->get_error_message(), + 'data' => $step_output->get_error_data(), + ); + + return $record; + } + + $record['status'] = WP_Agent_Workflow_Run_Result::STATUS_SUCCEEDED; + $record['output'] = is_array( $step_output ) ? $step_output : array( 'value' => $step_output ); + $record['ended_at'] = time(); + $context->set_step_output( $step_id, $record['output'] ); + + return $record; + } + + /** + * Expand a foreach step's outer fields while preserving nested step templates. + * + * @param array $step + * @param array $context + * @return array + */ + private static function expand_foreach_outer_step( array $step, array $context ): array { + $nested = $step['steps'] ?? array(); + unset( $step['steps'] ); + + $expanded = WP_Agent_Workflow_Bindings::expand( $step, $context ); + if ( ! is_array( $expanded ) ) { + $expanded = array(); + } + $expanded['steps'] = $nested; + + return $expanded; + } + + /** + * Return a string only for values that can safely be represented as text. + * + * @param mixed $value Value to normalize. + */ + private static function string_value( $value ): string { + if ( is_scalar( $value ) || $value instanceof \Stringable ) { + return (string) $value; + } + + return ''; + } +} diff --git a/tests/workflow-runner-smoke.php b/tests/workflow-runner-smoke.php index 8ef7d8e..a3ec7c9 100644 --- a/tests/workflow-runner-smoke.php +++ b/tests/workflow-runner-smoke.php @@ -168,6 +168,8 @@ function smoke_assert( $expected, $actual, string $name, array &$failures, int & require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-store.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-run-recorder.php'; require_once __DIR__ . '/../src/Abilities/class-wp-agent-ability-dispatcher.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-run-context.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-step-executor.php'; require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-runner.php'; use AgentsAPI\AI\Workflows\WP_Agent_Workflow_Run_Recorder; @@ -229,6 +231,13 @@ static function ( array $input ): \WP_Error { return new \WP_Error( 'demo_bang', 'something broke' ); } ); +workflow_runner_smoke_register_ability( + 'agents/chat', + static function ( array $input ): \WP_Error { + unset( $input ); + return new \WP_Error( 'agent_dispatch_failed', 'agent step failed' ); + } +); // Register all stub abilities with the real Abilities API (no-op in pure-PHP). do_action( 'wp_abilities_api_categories_init' ); @@ -328,6 +337,27 @@ static function ( array $input ): \WP_Error { smoke_assert( WP_Agent_Workflow_Run_Result::STATUS_FAILED, $result3->get_status(), 'continue_on_error still surfaces failure', $failures, $passes ); smoke_assert( 2, count( $result3->get_steps() ), 'continue_on_error executes the second step too', $failures, $passes ); +// ─── Agent step errors use the shared ability dispatcher path ───────── + +$agent_error_spec = WP_Agent_Workflow_Spec::from_array( + array( + 'id' => 'demo/agent-error', + 'steps' => array( + array( + 'id' => 'ask_agent', + 'type' => 'agent', + 'agent' => 'demo-agent', + 'message' => 'please fail', + ), + ), + ) +); + +$agent_error_result = ( new WP_Agent_Workflow_Runner( null ) )->run( $agent_error_spec ); +smoke_assert( WP_Agent_Workflow_Run_Result::STATUS_FAILED, $agent_error_result->get_status(), 'agent step error fails run', $failures, $passes ); +smoke_assert( 'agent_dispatch_failed', $agent_error_result->get_error()['code'], 'agent step surfaces dispatcher error code', $failures, $passes ); +smoke_assert( 'ask_agent', $agent_error_result->get_steps()[0]['id'] ?? '', 'agent step error records step id', $failures, $passes ); + // ─── Required-input check ──────────────────────────────────────────── $result4 = ( new WP_Agent_Workflow_Runner( null ) )->run( $spec /* missing text */ );