diff --git a/agents-api.php b/agents-api.php index b11df97..cba029d 100644 --- a/agents-api.php +++ b/agents-api.php @@ -161,8 +161,9 @@ require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-runtime-tool-request-store.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-runtime-tool-continuation.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-runtime-tool-lifecycle.php'; -require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-conversation-result.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-run-control.php'; +require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-run-outcome.php'; +require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-conversation-result.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-chat-run-control.php'; require_once AGENTS_API_PATH . 'src/Tasks/class-wp-agent-task-run-control.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-conversation-loop.php'; diff --git a/composer.json b/composer.json index 18b8019..18247ad 100644 --- a/composer.json +++ b/composer.json @@ -83,6 +83,7 @@ "php tests/conversation-loop-events-smoke.php", "php tests/tool-declaration-rejection-smoke.php", "php tests/conversation-loop-interrupt-source-smoke.php", + "php tests/run-outcome-status-smoke.php", "php tests/iteration-budget-smoke.php", "php tests/conversation-loop-budgets-smoke.php", "php tests/channels-smoke.php", diff --git a/src/Channels/register-agents-chat-ability.php b/src/Channels/register-agents-chat-ability.php index abca198..593ca28 100644 --- a/src/Channels/register-agents-chat-ability.php +++ b/src/Channels/register-agents-chat-ability.php @@ -41,6 +41,7 @@ use AgentsAPI\AI\WP_Agent_Chat_Run_Control; use AgentsAPI\AI\WP_Agent_Execution_Principal; +use AgentsAPI\AI\WP_Agent_Run_Outcome; defined( 'ABSPATH' ) || exit; @@ -48,6 +49,11 @@ require_once dirname( __DIR__ ) . '/Runtime/class-wp-agent-chat-run-control.php'; } +if ( ! class_exists( WP_Agent_Run_Outcome::class ) ) { + require_once dirname( __DIR__ ) . '/Runtime/class-wp-agent-run-control.php'; + require_once dirname( __DIR__ ) . '/Runtime/class-wp-agent-run-outcome.php'; +} + /** * The slug under which this ability is registered. Stable. Consumers and * channels should target this string rather than a runtime-specific slug. @@ -205,23 +211,7 @@ function agents_chat_dispatch( array $input ) { WP_Agent_Chat_Run_Control::start_run( $result_run_id, $resolved_session_id, array( 'agent' => $agent ) ); } - $result_status = WP_Agent_Chat_Run_Control::normalize_status( $result['status'] ?? '' ); - if ( WP_Agent_Chat_Run_Control::STATUS_FAILED === $result_status ) { - $status = WP_Agent_Chat_Run_Control::STATUS_FAILED; - } elseif ( ! empty( $result['completed'] ) || ! array_key_exists( 'completed', $result ) ) { - $status = WP_Agent_Chat_Run_Control::STATUS_COMPLETED; - } elseif ( in_array( - $result_status, - array( - WP_Agent_Chat_Run_Control::STATUS_RUNTIME_TOOL_PENDING, - WP_Agent_Chat_Run_Control::STATUS_APPROVAL_REQUIRED, - ), - true - ) ) { - $status = $result_status; - } else { - $status = WP_Agent_Chat_Run_Control::STATUS_RUNNING; - } + $status = WP_Agent_Run_Outcome::run_control_status( $result ); WP_Agent_Chat_Run_Control::finish_run( $result_run_id, $status ); } diff --git a/src/Runtime/class-wp-agent-chat-run-control.php b/src/Runtime/class-wp-agent-chat-run-control.php index 7f0510b..6125d55 100644 --- a/src/Runtime/class-wp-agent-chat-run-control.php +++ b/src/Runtime/class-wp-agent-chat-run-control.php @@ -22,6 +22,9 @@ class WP_Agent_Chat_Run_Control { public const STATUS_FAILED = 'failed'; public const STATUS_RUNTIME_TOOL_PENDING = 'runtime_tool_pending'; public const STATUS_APPROVAL_REQUIRED = 'approval_required'; + public const STATUS_BUDGET_EXCEEDED = 'budget_exceeded'; + public const STATUS_STALLED = 'stalled'; + public const STATUS_INTERRUPTED = 'interrupted'; private const OPTION_KEY = 'agents_api_chat_run_control'; /** @return string[] */ @@ -35,6 +38,9 @@ public static function statuses(): array { self::STATUS_FAILED, self::STATUS_RUNTIME_TOOL_PENDING, self::STATUS_APPROVAL_REQUIRED, + self::STATUS_BUDGET_EXCEEDED, + self::STATUS_STALLED, + self::STATUS_INTERRUPTED, ); } diff --git a/src/Runtime/class-wp-agent-conversation-loop.php b/src/Runtime/class-wp-agent-conversation-loop.php index f1faf1a..155a96b 100644 --- a/src/Runtime/class-wp-agent-conversation-loop.php +++ b/src/Runtime/class-wp-agent-conversation-loop.php @@ -463,10 +463,7 @@ public static function run( array $messages, ?callable $turn_runner = null, arra $final_result = self::normalize_conversation_result( $final_result_data ); if ( '' !== $run_id && '' !== $lock_session_id ) { - WP_Agent_Chat_Run_Control::finish_run( - $run_id, - null !== $interrupted ? WP_Agent_Chat_Run_Control::STATUS_CANCELLED : WP_Agent_Chat_Run_Control::STATUS_COMPLETED - ); + WP_Agent_Chat_Run_Control::finish_run( $run_id, WP_Agent_Run_Outcome::run_control_status( $final_result ) ); } self::persist_transcript( $transcript_persister, $messages, $options, $final_result ); diff --git a/src/Runtime/class-wp-agent-conversation-result.php b/src/Runtime/class-wp-agent-conversation-result.php index 77fe6cc..0e66001 100644 --- a/src/Runtime/class-wp-agent-conversation-result.php +++ b/src/Runtime/class-wp-agent-conversation-result.php @@ -20,16 +20,16 @@ class WP_Agent_Conversation_Result { public const SCHEMA = 'agents-api.conversation-result'; public const VERSION = 1; - public const RUN_OUTCOME_SCHEMA = 'agents-api.run-outcome'; - public const RUN_OUTCOME_VERSION = 1; + public const RUN_OUTCOME_SCHEMA = WP_Agent_Run_Outcome::SCHEMA; + public const RUN_OUTCOME_VERSION = WP_Agent_Run_Outcome::VERSION; - public const OUTCOME_STATUS_COMPLETED = 'completed'; - public const OUTCOME_STATUS_INCOMPLETE = 'incomplete'; - public const OUTCOME_STATUS_FAILED = 'failed'; - public const OUTCOME_STATUS_PENDING_RUNTIME_TOOL = 'runtime_tool_pending'; - public const OUTCOME_STOP_NATURAL = 'natural'; - public const OUTCOME_STOP_MAX_TURNS = 'max_turns'; - public const OUTCOME_STOP_PROVIDER_ERROR = 'provider_error'; + public const OUTCOME_STATUS_COMPLETED = WP_Agent_Run_Outcome::STATUS_COMPLETED; + public const OUTCOME_STATUS_INCOMPLETE = WP_Agent_Run_Outcome::STATUS_INCOMPLETE; + public const OUTCOME_STATUS_FAILED = WP_Agent_Run_Outcome::STATUS_FAILED; + public const OUTCOME_STATUS_PENDING_RUNTIME_TOOL = WP_Agent_Run_Outcome::STATUS_RUNTIME_TOOL_PENDING; + public const OUTCOME_STOP_NATURAL = WP_Agent_Run_Outcome::STOP_NATURAL; + public const OUTCOME_STOP_MAX_TURNS = WP_Agent_Run_Outcome::STOP_MAX_TURNS; + public const OUTCOME_STOP_PROVIDER_ERROR = WP_Agent_Run_Outcome::STOP_PROVIDER_ERROR; /** * Validate and normalize a loop result. @@ -252,127 +252,11 @@ public static function normalize( array $result ): array { throw self::invalid( 'runtime_tool_pending', 'must be an array when present' ); } - $result['run_outcome'] = self::normalizeRunOutcome( $result['run_outcome'] ?? null, $result ); + $result['run_outcome'] = WP_Agent_Run_Outcome::normalize( $result['run_outcome'] ?? null, $result ); return $result; } - /** - * Normalize the stable run outcome envelope. - * - * The envelope is intentionally generic: products can map it to their own - * artifact/remediation contracts without parsing provider or runtime metadata. - * - * @param mixed $outcome Raw outcome value. - * @param array $result Normalized conversation result fields. - * @return array - */ - private static function normalizeRunOutcome( $outcome, array $result ): array { - $raw = is_array( $outcome ) ? $outcome : array(); - $status = self::stringValue( $raw['status'] ?? null ); - $completed = array_key_exists( 'completed', $raw ) ? (bool) $raw['completed'] : (bool) ( $result['completed'] ?? true ); - - if ( '' === $status ) { - $status = self::deriveOutcomeStatus( $result, $completed ); - } - - $normalized = array( - 'schema' => self::RUN_OUTCOME_SCHEMA, - 'version' => self::RUN_OUTCOME_VERSION, - 'status' => $status, - 'completed' => $completed, - 'stop_reason' => self::deriveStopReason( $raw, $result, $status, $completed ), - 'retryable' => array_key_exists( 'retryable', $raw ) ? (bool) $raw['retryable'] : self::deriveRetryable( $result, $status ), - ); - - if ( isset( $raw['failure'] ) && is_array( $raw['failure'] ) ) { - $normalized['failure'] = self::stringKeyedArray( $raw['failure'] ); - } elseif ( isset( $result['failure'] ) && is_array( $result['failure'] ) ) { - $normalized['failure'] = self::stringKeyedArray( $result['failure'] ); - } - - if ( isset( $raw['assertions'] ) && is_array( $raw['assertions'] ) ) { - $normalized['assertions'] = self::stringKeyedArray( $raw['assertions'] ); - } - - if ( isset( $raw['provider_error'] ) && is_array( $raw['provider_error'] ) ) { - $normalized['provider_error'] = self::stringKeyedArray( $raw['provider_error'] ); - } elseif ( isset( $result['failure'] ) && is_array( $result['failure'] ) && self::OUTCOME_STOP_PROVIDER_ERROR === $normalized['stop_reason'] ) { - $normalized['provider_error'] = self::stringKeyedArray( $result['failure'] ); - } - - if ( isset( $raw['metadata'] ) && is_array( $raw['metadata'] ) ) { - $normalized['metadata'] = self::stringKeyedArray( $raw['metadata'] ); - } - - return $normalized; - } - - /** @param array $result */ - private static function deriveOutcomeStatus( array $result, bool $completed ): string { - $status = self::stringValue( $result['status'] ?? null ); - if ( self::OUTCOME_STATUS_PENDING_RUNTIME_TOOL === $status ) { - return self::OUTCOME_STATUS_PENDING_RUNTIME_TOOL; - } - if ( 'failed' === $status || isset( $result['failure'] ) ) { - return self::OUTCOME_STATUS_FAILED; - } - return $completed ? self::OUTCOME_STATUS_COMPLETED : self::OUTCOME_STATUS_INCOMPLETE; - } - - /** - * @param array $raw Raw outcome fields. - * @param array $result Normalized result fields. - */ - private static function deriveStopReason( array $raw, array $result, string $status, bool $completed ): string { - $stop_reason = self::stringValue( $raw['stop_reason'] ?? null ); - if ( '' !== $stop_reason ) { - return $stop_reason; - } - - $result_status = self::stringValue( $result['status'] ?? null ); - if ( 'budget_exceeded' === $result_status && 'turns' === self::stringValue( $result['budget'] ?? null ) ) { - return self::OUTCOME_STOP_MAX_TURNS; - } - if ( self::OUTCOME_STATUS_PENDING_RUNTIME_TOOL === $status ) { - return self::OUTCOME_STATUS_PENDING_RUNTIME_TOOL; - } - if ( self::OUTCOME_STATUS_FAILED === $status ) { - return self::OUTCOME_STOP_PROVIDER_ERROR; - } - return $completed ? self::OUTCOME_STOP_NATURAL : $result_status; - } - - /** @param array $result */ - private static function deriveRetryable( array $result, string $status ): bool { - if ( self::OUTCOME_STATUS_PENDING_RUNTIME_TOOL === $status ) { - return false; - } - if ( 'budget_exceeded' === self::stringValue( $result['status'] ?? null ) ) { - return true; - } - return self::OUTCOME_STATUS_FAILED === $status; - } - - /** @param mixed $value Raw value. */ - private static function stringValue( $value ): string { - return is_scalar( $value ) ? trim( (string) $value ) : ''; - } - - /** - * @param array $value Raw array. - * @return array - */ - private static function stringKeyedArray( array $value ): array { - $normalized = array(); - foreach ( $value as $key => $item ) { - if ( is_string( $key ) ) { - $normalized[ $key ] = $item; - } - } - return $normalized; - } - /** * Build a machine-readable validation exception. * diff --git a/src/Runtime/class-wp-agent-run-control.php b/src/Runtime/class-wp-agent-run-control.php index cea29d6..e7b9f49 100644 --- a/src/Runtime/class-wp-agent-run-control.php +++ b/src/Runtime/class-wp-agent-run-control.php @@ -23,6 +23,9 @@ class WP_Agent_Run_Control { public const STATUS_FAILED = 'failed'; public const STATUS_RUNTIME_TOOL_PENDING = 'runtime_tool_pending'; public const STATUS_APPROVAL_REQUIRED = 'approval_required'; + public const STATUS_BUDGET_EXCEEDED = 'budget_exceeded'; + public const STATUS_STALLED = 'stalled'; + public const STATUS_INTERRUPTED = 'interrupted'; /** @return string[] */ public static function statuses(): array { @@ -36,6 +39,9 @@ public static function statuses(): array { self::STATUS_FAILED, self::STATUS_RUNTIME_TOOL_PENDING, self::STATUS_APPROVAL_REQUIRED, + self::STATUS_BUDGET_EXCEEDED, + self::STATUS_STALLED, + self::STATUS_INTERRUPTED, ); } @@ -199,7 +205,7 @@ public static function request_cancel( string $store_key, string $run_id ): ?arr } $run = $state['runs'][ $run_id ]; - $terminal = in_array( self::normalize_status( $run['status'] ?? '' ), array( self::STATUS_COMPLETED, self::STATUS_SUCCEEDED, self::STATUS_FAILED, self::STATUS_CANCELLED ), true ); + $terminal = in_array( self::normalize_status( $run['status'] ?? '' ), array( self::STATUS_COMPLETED, self::STATUS_SUCCEEDED, self::STATUS_FAILED, self::STATUS_CANCELLED, self::STATUS_BUDGET_EXCEEDED, self::STATUS_STALLED, self::STATUS_INTERRUPTED ), true ); $run['status'] = $terminal ? self::normalize_status( $run['status'] ?? '' ) : self::STATUS_CANCELLING; $run['cancelled'] = ! $terminal; $run['updated_at'] = self::now(); diff --git a/src/Runtime/class-wp-agent-run-outcome.php b/src/Runtime/class-wp-agent-run-outcome.php new file mode 100644 index 0000000..0a9aa2f --- /dev/null +++ b/src/Runtime/class-wp-agent-run-outcome.php @@ -0,0 +1,213 @@ + $result Normalized conversation result fields. + * @return array + */ + public static function normalize( $outcome, array $result = array() ): array { + $raw = is_array( $outcome ) ? $outcome : array(); + $status = self::normalize_status( $raw['status'] ?? null ); + $completed = array_key_exists( 'completed', $raw ) ? (bool) $raw['completed'] : (bool) ( $result['completed'] ?? true ); + + if ( '' === $status ) { + $status = self::derive_status( $result, $completed ); + } + if ( self::STATUS_COMPLETED !== $status ) { + $completed = false; + } + + $normalized = array( + 'schema' => self::SCHEMA, + 'version' => self::VERSION, + 'status' => $status, + 'completed' => $completed, + 'stop_reason' => self::derive_stop_reason( $raw, $result, $status, $completed ), + 'retryable' => array_key_exists( 'retryable', $raw ) ? (bool) $raw['retryable'] : self::derive_retryable( $result, $status ), + ); + + if ( isset( $raw['failure'] ) && is_array( $raw['failure'] ) ) { + $normalized['failure'] = self::string_keyed_array( $raw['failure'] ); + } elseif ( isset( $result['failure'] ) && is_array( $result['failure'] ) ) { + $normalized['failure'] = self::string_keyed_array( $result['failure'] ); + } + + if ( isset( $raw['assertions'] ) && is_array( $raw['assertions'] ) ) { + $normalized['assertions'] = self::string_keyed_array( $raw['assertions'] ); + } + + if ( isset( $raw['provider_error'] ) && is_array( $raw['provider_error'] ) ) { + $normalized['provider_error'] = self::string_keyed_array( $raw['provider_error'] ); + } elseif ( isset( $result['failure'] ) && is_array( $result['failure'] ) && self::STOP_PROVIDER_ERROR === $normalized['stop_reason'] ) { + $normalized['provider_error'] = self::string_keyed_array( $result['failure'] ); + } + + if ( isset( $raw['metadata'] ) && is_array( $raw['metadata'] ) ) { + $normalized['metadata'] = self::string_keyed_array( $raw['metadata'] ); + } + + return $normalized; + } + + /** + * Map an outcome or result to the generic run-control status vocabulary. + * + * @param array $outcome_or_result Normalized outcome or conversation result. + */ + public static function run_control_status( array $outcome_or_result ): string { + $outcome = self::normalize( $outcome_or_result['run_outcome'] ?? null, $outcome_or_result ); + + switch ( $outcome['status'] ) { + case self::STATUS_RUNTIME_TOOL_PENDING: + return WP_Agent_Run_Control::STATUS_RUNTIME_TOOL_PENDING; + case self::STATUS_APPROVAL_REQUIRED: + return WP_Agent_Run_Control::STATUS_APPROVAL_REQUIRED; + case self::STATUS_BUDGET_EXCEEDED: + return WP_Agent_Run_Control::STATUS_BUDGET_EXCEEDED; + case self::STATUS_STALLED: + return WP_Agent_Run_Control::STATUS_STALLED; + case self::STATUS_CANCELLED: + return WP_Agent_Run_Control::STATUS_CANCELLED; + case self::STATUS_INTERRUPTED: + return WP_Agent_Run_Control::STATUS_INTERRUPTED; + case self::STATUS_FAILED: + return WP_Agent_Run_Control::STATUS_FAILED; + case self::STATUS_COMPLETED: + return WP_Agent_Run_Control::STATUS_COMPLETED; + default: + return WP_Agent_Run_Control::STATUS_RUNNING; + } + } + + private static function normalize_status( mixed $status ): string { + $status = self::string_value( $status ); + return in_array( $status, self::statuses(), true ) ? $status : ''; + } + + /** @param array $result */ + private static function derive_status( array $result, bool $completed ): string { + $status = self::string_value( $result['status'] ?? null ); + if ( self::STATUS_RUNTIME_TOOL_PENDING === $status ) { + return self::STATUS_RUNTIME_TOOL_PENDING; + } + if ( self::STATUS_APPROVAL_REQUIRED === $status ) { + return self::STATUS_APPROVAL_REQUIRED; + } + if ( self::STATUS_BUDGET_EXCEEDED === $status ) { + return self::STATUS_BUDGET_EXCEEDED; + } + if ( self::STATUS_STALLED === $status ) { + return self::STATUS_STALLED; + } + if ( self::STATUS_INTERRUPTED === $status ) { + return self::STATUS_INTERRUPTED; + } + if ( self::STATUS_CANCELLED === $status ) { + return self::STATUS_CANCELLED; + } + if ( self::STATUS_FAILED === $status || isset( $result['failure'] ) ) { + return self::STATUS_FAILED; + } + return $completed ? self::STATUS_COMPLETED : self::STATUS_INCOMPLETE; + } + + /** + * @param array $raw Raw outcome fields. + * @param array $result Normalized result fields. + */ + private static function derive_stop_reason( array $raw, array $result, string $status, bool $completed ): string { + $stop_reason = self::string_value( $raw['stop_reason'] ?? null ); + if ( '' !== $stop_reason ) { + return $stop_reason; + } + + $result_status = self::string_value( $result['status'] ?? null ); + if ( self::STATUS_BUDGET_EXCEEDED === $result_status && 'turns' === self::string_value( $result['budget'] ?? null ) ) { + return self::STOP_MAX_TURNS; + } + if ( self::STATUS_FAILED === $status ) { + return self::STOP_PROVIDER_ERROR; + } + if ( self::STATUS_COMPLETED === $status && $completed ) { + return self::STOP_NATURAL; + } + return '' !== $result_status ? $result_status : $status; + } + + /** @param array $result */ + private static function derive_retryable( array $result, string $status ): bool { + if ( in_array( $status, array( self::STATUS_RUNTIME_TOOL_PENDING, self::STATUS_APPROVAL_REQUIRED, self::STATUS_CANCELLED, self::STATUS_INTERRUPTED ), true ) ) { + return false; + } + if ( self::STATUS_BUDGET_EXCEEDED === self::string_value( $result['status'] ?? null ) ) { + return true; + } + return self::STATUS_FAILED === $status; + } + + private static function string_value( mixed $value ): string { + return is_scalar( $value ) || $value instanceof \Stringable ? strtolower( trim( (string) $value ) ) : ''; + } + + /** + * @param array $value Raw array. + * @return array + */ + private static function string_keyed_array( array $value ): array { + $normalized = array(); + foreach ( $value as $key => $item ) { + if ( is_string( $key ) ) { + $normalized[ $key ] = $item; + } + } + return $normalized; + } +} diff --git a/tests/conversation-loop-budgets-smoke.php b/tests/conversation-loop-budgets-smoke.php index 815e9db..b3eb1e3 100644 --- a/tests/conversation-loop-budgets-smoke.php +++ b/tests/conversation-loop-budgets-smoke.php @@ -298,7 +298,7 @@ static function ( array $messages, array $context ) use ( &$turn_count ): array agents_api_smoke_assert_equals( 3, $turn_count, 'explicit turns budget overrides higher max_turns', $failures, $passes ); agents_api_smoke_assert_equals( 'budget_exceeded', $result['status'] ?? null, 'explicit turns budget produces budget_exceeded status', $failures, $passes ); agents_api_smoke_assert_equals( 'turns', $result['budget'] ?? null, 'explicit turns budget identified in result', $failures, $passes ); -agents_api_smoke_assert_equals( AgentsAPI\AI\WP_Agent_Conversation_Result::OUTCOME_STATUS_INCOMPLETE, $result['run_outcome']['status'] ?? '', 'explicit turns budget run outcome is incomplete', $failures, $passes ); +agents_api_smoke_assert_equals( AgentsAPI\AI\WP_Agent_Run_Outcome::STATUS_BUDGET_EXCEEDED, $result['run_outcome']['status'] ?? '', 'explicit turns budget run outcome is budget_exceeded', $failures, $passes ); agents_api_smoke_assert_equals( AgentsAPI\AI\WP_Agent_Conversation_Result::OUTCOME_STOP_MAX_TURNS, $result['run_outcome']['stop_reason'] ?? '', 'explicit turns budget run outcome stop reason is max turns', $failures, $passes ); agents_api_smoke_assert_equals( true, $result['run_outcome']['retryable'] ?? false, 'explicit turns budget run outcome is retryable', $failures, $passes ); diff --git a/tests/run-outcome-status-smoke.php b/tests/run-outcome-status-smoke.php new file mode 100644 index 0000000..53a9f51 --- /dev/null +++ b/tests/run-outcome-status-smoke.php @@ -0,0 +1,230 @@ + true, + 'tool_name' => $tool_call['tool_name'], + 'result' => array( 'ok' => true ), + ); + } +}; + +$approval_envelope = AgentsAPI\AI\WP_Agent_Message::approvalRequired( + 'Do protected work.', + array( + 'action_id' => 'approve-1', + 'summary' => 'Do protected work.', + ) +); + +$approval_executor = new class( $approval_envelope ) implements AgentsAPI\AI\Tools\WP_Agent_Tool_Executor { + /** @var array */ + private array $approval_envelope; + + /** @param array $approval_envelope */ + public function __construct( array $approval_envelope ) { + $this->approval_envelope = $approval_envelope; + } + + public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definition, array $context = array() ): array { + unset( $tool_definition, $context ); + return array( + 'success' => true, + 'tool_name' => $tool_call['tool_name'], + 'result' => $this->approval_envelope, + ); + } +}; + +$tools = array( + 'client/work' => array( + 'name' => 'client/work', + 'source' => 'client', + 'description' => 'Do work.', + 'parameters' => array(), + 'executor' => 'client', + 'scope' => 'run', + ), +); + +$run_status = static function ( string $run_id ): ?string { + $run = AgentsAPI\AI\WP_Agent_Chat_Run_Control::get_run( $run_id ); + return is_array( $run ) ? ( $run['status'] ?? null ) : null; +}; + +echo "\n[1] completed runs stay completed:\n"; +AgentsAPI\AI\WP_Agent_Conversation_Loop::run( + array( array( 'role' => 'user', 'content' => 'go' ) ), + static function ( array $messages ): array { + $messages[] = AgentsAPI\AI\WP_Agent_Message::text( 'assistant', 'done' ); + return array( + 'messages' => $messages, + 'tool_execution_results' => array(), + 'events' => array(), + ); + }, + array( + 'run_id' => 'run-completed', + 'transcript_session_id' => 'session-completed', + ) +); +agents_api_smoke_assert_equals( 'completed', $run_status( 'run-completed' ), 'completed loop finalizes run as completed', $failures, $passes ); + +echo "\n[2] pending runtime tool remains pending:\n"; +$pending = AgentsAPI\AI\WP_Agent_Conversation_Loop::run( + array( array( 'role' => 'user', 'content' => 'go' ) ), + static fn( array $messages ): array => array( + 'messages' => $messages, + 'tool_calls' => array( array( 'name' => 'client/work', 'parameters' => array() ) ), + ), + array( + 'run_id' => 'run-pending', + 'transcript_session_id' => 'session-pending', + 'tool_executor' => $executor, + 'tool_declarations' => $tools, + 'pre_tool_mediator' => static fn(): array => array( + 'action' => 'pending', + 'runtime_tool_request' => array( 'request_id' => 'request-1' ), + ), + ) +); +agents_api_smoke_assert_equals( 'runtime_tool_pending', $pending['run_outcome']['status'] ?? null, 'runtime-tool outcome is explicit', $failures, $passes ); +agents_api_smoke_assert_equals( 'runtime_tool_pending', $run_status( 'run-pending' ), 'runtime-tool run status is not completed', $failures, $passes ); + +echo "\n[3] approval-required remains approval-required:\n"; +$approval = AgentsAPI\AI\WP_Agent_Conversation_Loop::run( + array( array( 'role' => 'user', 'content' => 'go' ) ), + static fn( array $messages ): array => array( + 'messages' => $messages, + 'tool_calls' => array( array( 'name' => 'client/work', 'parameters' => array() ) ), + ), + array( + 'run_id' => 'run-approval', + 'transcript_session_id' => 'session-approval', + 'tool_executor' => $approval_executor, + 'tool_declarations' => $tools, + ) +); +agents_api_smoke_assert_equals( 'approval_required', $approval['run_outcome']['status'] ?? null, 'approval outcome is explicit', $failures, $passes ); +agents_api_smoke_assert_equals( 'approval_required', $run_status( 'run-approval' ), 'approval run status is not completed', $failures, $passes ); + +echo "\n[4] budget exhaustion finalizes distinctly:\n"; +$budget = AgentsAPI\AI\WP_Agent_Conversation_Loop::run( + array( array( 'role' => 'user', 'content' => 'go' ) ), + static fn( array $messages ): array => array( + 'messages' => $messages, + 'tool_execution_results' => array(), + 'events' => array(), + ), + array( + 'run_id' => 'run-budget', + 'transcript_session_id' => 'session-budget', + 'max_turns' => 10, + 'budgets' => array( new AgentsAPI\AI\WP_Agent_Iteration_Budget( 'turns', 1 ) ), + 'should_continue' => static fn(): bool => true, + ) +); +agents_api_smoke_assert_equals( 'budget_exceeded', $budget['run_outcome']['status'] ?? null, 'budget outcome is explicit', $failures, $passes ); +agents_api_smoke_assert_equals( 'budget_exceeded', $run_status( 'run-budget' ), 'budget run status is not completed', $failures, $passes ); + +echo "\n[5] stalled finalizes distinctly:\n"; +$spin_detector = new class() implements AgentsAPI\AI\WP_Agent_Spin_Detector { + public function record_signature( AgentsAPI\AI\WP_Agent_Spin_Signature $signature, array $context = array() ): bool { + unset( $signature, $context ); + return true; + } + public function repeat_count(): int { return 2; } + public function threshold(): int { return 2; } +}; +$stalled = AgentsAPI\AI\WP_Agent_Conversation_Loop::run( + array( array( 'role' => 'user', 'content' => 'go' ) ), + static fn( array $messages ): array => array( + 'messages' => $messages, + 'tool_calls' => array( array( 'name' => 'client/work', 'parameters' => array() ) ), + ), + array( + 'run_id' => 'run-stalled', + 'transcript_session_id' => 'session-stalled', + 'tool_executor' => $executor, + 'tool_declarations' => $tools, + 'spin_detector' => $spin_detector, + ) +); +agents_api_smoke_assert_equals( 'stalled', $stalled['run_outcome']['status'] ?? null, 'stalled outcome is explicit', $failures, $passes ); +agents_api_smoke_assert_equals( 'stalled', $run_status( 'run-stalled' ), 'stalled run status is not completed', $failures, $passes ); + +echo "\n[6] failed finalizes distinctly:\n"; +$failed = AgentsAPI\AI\WP_Agent_Conversation_Loop::run( + array( array( 'role' => 'user', 'content' => 'go' ) ), + static fn(): array => array( + 'failure' => array( + 'type' => 'provider_error', + 'message' => 'Provider failed.', + ), + ), + array( + 'run_id' => 'run-failed', + 'transcript_session_id' => 'session-failed', + ) +); +agents_api_smoke_assert_equals( 'failed', $failed['run_outcome']['status'] ?? null, 'failed outcome is explicit', $failures, $passes ); +agents_api_smoke_assert_equals( 'failed', $run_status( 'run-failed' ), 'failed run status is not completed', $failures, $passes ); + +echo "\n[7] cancellation interrupt finalizes distinctly:\n"; +$interrupted = AgentsAPI\AI\WP_Agent_Conversation_Loop::run( + array( array( 'role' => 'user', 'content' => 'go' ) ), + static fn( array $messages ): array => array( + 'messages' => $messages, + 'tool_execution_results' => array(), + 'events' => array(), + ), + array( + 'run_id' => 'run-interrupted', + 'transcript_session_id' => 'session-interrupted', + 'max_turns' => 2, + 'should_continue' => static fn(): bool => true, + 'interrupt_source' => static fn(): array => AgentsAPI\AI\WP_Agent_Chat_Run_Control::cancellation_interrupt_message( 'run-interrupted', 'session-interrupted' ), + ) +); +agents_api_smoke_assert_equals( 'interrupted', $interrupted['run_outcome']['status'] ?? null, 'interrupted outcome is explicit', $failures, $passes ); +agents_api_smoke_assert_equals( 'interrupted', $run_status( 'run-interrupted' ), 'interrupted run status is not completed', $failures, $passes ); + +agents_api_smoke_finish( 'run outcome status', $failures, $passes );