Skip to content
Merged
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
3 changes: 2 additions & 1 deletion agents-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 7 additions & 17 deletions src/Channels/register-agents-chat-ability.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,19 @@

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;

if ( ! class_exists( WP_Agent_Chat_Run_Control::class ) ) {
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.
Expand Down Expand Up @@ -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 );
}

Expand Down
6 changes: 6 additions & 0 deletions src/Runtime/class-wp-agent-chat-run-control.php
Original file line number Diff line number Diff line change
Expand Up @@ -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[] */
Expand All @@ -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,
);
}

Expand Down
5 changes: 1 addition & 4 deletions src/Runtime/class-wp-agent-conversation-loop.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down
136 changes: 10 additions & 126 deletions src/Runtime/class-wp-agent-conversation-result.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<mixed> $result Normalized conversation result fields.
* @return array<string,mixed>
*/
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<mixed> $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<mixed> $raw Raw outcome fields.
* @param array<mixed> $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<mixed> $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<mixed> $value Raw array.
* @return array<string,mixed>
*/
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.
*
Expand Down
8 changes: 7 additions & 1 deletion src/Runtime/class-wp-agent-run-control.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
);
}

Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading