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
4 changes: 3 additions & 1 deletion docs/channels-workflows-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,16 @@ The channel can consume either a single `reply` or assistant `messages` from the

## Chat run control

Agents API owns the generic run-control ability contracts while runtimes own concrete storage, workers, provider aborts, and queue draining.
Agents API owns the generic run-control ability contracts and default run-control storage. Every `agents/chat` run receives a `run_id`, stores status when a session is known, accepts best-effort cancellation, and accepts queued follow-up messages. Runtimes may override the hooks below only when they can provide stronger behavior such as immediate provider aborts or a custom queue worker.

| Ability | Purpose | Runtime hook |
| --- | --- | --- |
| `agents/get-chat-run` | Return status for a known chat run. | `wp_agent_chat_run_status_handler` |
| `agents/cancel-chat-run` | Request best-effort cancellation for a known chat run. | `wp_agent_chat_run_cancel_handler` |
| `agents/queue-chat-message` | Accept a next user message while a session has an active run. | `wp_agent_chat_message_queue_handler` |

Clients can expose status, Stop, and Queue controls whenever these canonical abilities are available and the caller has permission for the selected agent. The default handlers preserve safe behavior for synchronous runtimes; runtime-specific handlers are enhancements, not prerequisites.

Run status vocabulary is bounded to `queued`, `running`, `cancelling`, `cancelled`, `completed`, and `failed`. The canonical run payload is:

```php
Expand Down
32 changes: 31 additions & 1 deletion src/Channels/register-agents-chat-ability.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@

defined( 'ABSPATH' ) || exit;

if ( ! class_exists( WP_Agent_Chat_Run_Control::class ) ) {
require_once dirname( __DIR__ ) . '/Runtime/class-wp-agent-chat-run-control.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 @@ -143,16 +147,30 @@ function agents_chat_dispatch( array $input ) {
);
}

$run_id = (string) $input['run_id'];
$session_id = trim( (string) ( $input['session_id'] ?? '' ) );
if ( '' !== $session_id ) {
WP_Agent_Chat_Run_Control::start_run( $run_id, $session_id, array( 'agent' => (string) ( $input['agent'] ?? '' ) ) );
}

$result = call_user_func( $handler, $input );

if ( is_wp_error( $result ) ) {
if ( '' !== $session_id ) {
WP_Agent_Chat_Run_Control::finish_run( $run_id, WP_Agent_Chat_Run_Control::STATUS_FAILED );
}

/** This action is documented above. */
do_action( 'agents_chat_dispatch_failed', $result->get_error_code(), $input );

return $result;
}

if ( ! is_array( $result ) ) {
if ( '' !== $session_id ) {
WP_Agent_Chat_Run_Control::finish_run( $run_id, WP_Agent_Chat_Run_Control::STATUS_FAILED );
}

/** This action is documented above. */
do_action( 'agents_chat_dispatch_failed', 'invalid_result', $input );

Expand All @@ -166,6 +184,18 @@ function agents_chat_dispatch( array $input ) {
$result['run_id'] = $input['run_id'];
}

$resolved_session_id = trim( (string) ( $result['session_id'] ?? $session_id ) );
if ( '' !== $resolved_session_id ) {
if ( '' === $session_id ) {
WP_Agent_Chat_Run_Control::start_run( (string) $result['run_id'], $resolved_session_id, array( 'agent' => (string) ( $input['agent'] ?? '' ) ) );
}

$status = ! empty( $result['completed'] ) || ! array_key_exists( 'completed', $result )
? WP_Agent_Chat_Run_Control::STATUS_COMPLETED
: WP_Agent_Chat_Run_Control::STATUS_RUNNING;
WP_Agent_Chat_Run_Control::finish_run( (string) $result['run_id'], $status );
}

return $result;
}

Expand Down Expand Up @@ -339,7 +369,7 @@ function agents_chat_output_schema(): array {
),
'run_id' => array(
'type' => 'string',
'description' => 'Opaque ID for this accepted chat turn. Use with agents/get-chat-run, agents/cancel-chat-run, and agents/queue-chat-message when the runtime supports run control.',
'description' => 'Opaque ID for this accepted chat turn. Use with agents/get-chat-run, agents/cancel-chat-run, and agents/queue-chat-message for generic run control.',
),
'messages' => array(
'type' => 'array',
Expand Down
74 changes: 64 additions & 10 deletions src/Channels/register-agents-chat-run-control-abilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,21 +79,43 @@ static function (): void {
/** @return array<string,mixed>|\WP_Error */
function agents_get_chat_run( array $input ) {
$handler = apply_filters( 'wp_agent_chat_run_status_handler', null, $input );
if ( ! is_callable( $handler ) ) {
return agents_chat_run_control_no_handler( 'agents_chat_run_status_unsupported', 'No chat run status handler is registered.' );
if ( is_callable( $handler ) ) {
return agents_chat_run_control_normalize_result( call_user_func( $handler, $input ), 'agents_chat_run_invalid_status' );
}

return agents_chat_run_control_normalize_result( call_user_func( $handler, $input ), 'agents_chat_run_invalid_status' );
$run = WP_Agent_Chat_Run_Control::get_run( (string) ( $input['run_id'] ?? '' ) );
$requested_session_id = (string) ( $input['session_id'] ?? '' );
if ( null !== $run && $requested_session_id !== (string) $run['session_id'] ) {
return agents_chat_run_control_no_handler( 'agents_chat_run_not_found', 'No chat run was found for the requested session_id and run_id.' );
}
if ( null !== $run ) {
return $run;
}

return agents_chat_run_control_no_handler( 'agents_chat_run_not_found', 'No chat run was found for the requested run_id.' );
}

/** @return array<string,mixed>|\WP_Error */
function agents_cancel_chat_run( array $input ) {
$handler = apply_filters( 'wp_agent_chat_run_cancel_handler', null, $input );
if ( ! is_callable( $handler ) ) {
return agents_chat_run_control_no_handler( 'agents_chat_run_cancel_unsupported', 'No chat run cancellation handler is registered.' );
if ( is_callable( $handler ) ) {
$result = agents_chat_run_control_normalize_result( call_user_func( $handler, $input ), 'agents_chat_run_invalid_cancel_result' );
} else {
$run = WP_Agent_Chat_Run_Control::get_run( (string) ( $input['run_id'] ?? '' ) );
$requested_session_id = (string) ( $input['session_id'] ?? '' );
if ( null === $run ) {
return agents_chat_run_control_no_handler( 'agents_chat_run_not_found', 'No chat run was found for the requested run_id.' );
}
if ( $requested_session_id !== (string) $run['session_id'] ) {
return agents_chat_run_control_no_handler( 'agents_chat_run_not_found', 'No chat run was found for the requested session_id and run_id.' );
}

$result = WP_Agent_Chat_Run_Control::request_cancel( (string) ( $input['run_id'] ?? '' ) );
if ( null === $result ) {
return agents_chat_run_control_no_handler( 'agents_chat_run_not_found', 'No chat run was found for the requested run_id.' );
}
}

$result = agents_chat_run_control_normalize_result( call_user_func( $handler, $input ), 'agents_chat_run_invalid_cancel_result' );
if ( is_wp_error( $result ) ) {
return $result;
}
Expand All @@ -113,11 +135,16 @@ function agents_cancel_chat_run( array $input ) {
/** @return array<string,mixed>|\WP_Error */
function agents_queue_chat_message( array $input ) {
$handler = apply_filters( 'wp_agent_chat_message_queue_handler', null, $input );
if ( ! is_callable( $handler ) ) {
return agents_chat_run_control_no_handler( 'agents_chat_message_queue_unsupported', 'No chat message queue handler is registered.' );
if ( is_callable( $handler ) ) {
$result = agents_chat_run_control_normalize_result( call_user_func( $handler, $input ), 'agents_chat_message_queue_invalid_result' );
} else {
try {
$result = WP_Agent_Chat_Run_Control::queue_message( $input );
} catch ( \InvalidArgumentException $error ) {
return new \WP_Error( 'agents_chat_message_queue_invalid_result', $error->getMessage() );
}
}

$result = agents_chat_run_control_normalize_result( call_user_func( $handler, $input ), 'agents_chat_message_queue_invalid_result' );
if ( is_wp_error( $result ) ) {
return $result;
}
Expand All @@ -133,10 +160,37 @@ function agents_queue_chat_message( array $input ) {
}

function agents_chat_run_control_permission( array $input ): bool {
$allowed = function_exists( 'current_user_can' ) ? current_user_can( 'read' ) : false;
$agent = sanitize_title( (string) ( $input['agent'] ?? '' ) );
if ( '' !== $agent && class_exists( '\WP_Agent_Access' ) && class_exists( '\WP_Agent_Access_Grant' ) ) {
$allowed = \WP_Agent_Access::can_current_principal_access_agent(
$agent,
\WP_Agent_Access_Grant::ROLE_VIEWER,
agents_chat_run_control_request_scope( $input )
);
} else {
$allowed = function_exists( 'current_user_can' ) ? current_user_can( 'read' ) : false;
}

return (bool) apply_filters( 'agents_chat_run_control_permission', $allowed, $input );
}

/**
* Extract request-scope fields for run-control access checks.
*
* @param array<string,mixed> $input Ability input.
* @return array<string,mixed> Access request scope.
*/
function agents_chat_run_control_request_scope( array $input ): array {
$scope = array();
foreach ( array( 'workspace_id', 'workspace_type', 'request_context', 'client_id', 'audience_id' ) as $key ) {
if ( isset( $input[ $key ] ) && is_scalar( $input[ $key ] ) ) {
$scope[ $key ] = (string) $input[ $key ];
}
}

return $scope;
}

/** @return array<string,mixed>|\WP_Error */
function agents_chat_run_control_normalize_result( $result, string $error_code ) {
if ( is_wp_error( $result ) ) {
Expand Down
182 changes: 182 additions & 0 deletions src/Runtime/class-wp-agent-chat-run-control.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class WP_Agent_Chat_Run_Control {
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
private const OPTION_KEY = 'agents_api_chat_run_control';

/** @return string[] */
public static function statuses(): array {
Expand Down Expand Up @@ -100,6 +101,167 @@ public static function normalize_status( $status ): string {
return in_array( $status, self::statuses(), true ) ? $status : self::STATUS_RUNNING;
}

/**
* Start or update an addressable chat run in the default store.
*
* @param string $run_id Run ID.
* @param string $session_id Session ID.
* @param array<string,mixed> $metadata Run metadata.
* @return array<string,mixed> Normalized run.
*/
public static function start_run( string $run_id, string $session_id, array $metadata = array() ): array {
$now = self::now();
$run = array(
'run_id' => $run_id,
'session_id' => $session_id,
'status' => self::STATUS_RUNNING,
'started_at' => $metadata['started_at'] ?? $now,
'updated_at' => $now,
'metadata' => $metadata,
);

$state = self::state();
$state['runs'][ $run_id ] = $run;
self::save_state( $state );

return self::normalize_run( $run );
}

/**
* Complete a stored chat run.
*
* @param string $run_id Run ID.
* @param string $status Terminal status.
* @return array<string,mixed>|null Normalized run, or null when absent.
*/
public static function finish_run( string $run_id, string $status = self::STATUS_COMPLETED ): ?array {
$state = self::state();
if ( ! isset( $state['runs'][ $run_id ] ) ) {
return null;
}

$run = $state['runs'][ $run_id ];
$run['status'] = self::normalize_status( $status );
$run['updated_at'] = self::now();
if ( self::STATUS_CANCELLED === $run['status'] ) {
$run['cancelled'] = true;
}

$state['runs'][ $run_id ] = $run;
self::save_state( $state );

return self::normalize_run( $run );
}

/**
* Read a stored chat run.
*
* @param string $run_id Run ID.
* @return array<string,mixed>|null Normalized run, or null when absent.
*/
public static function get_run( string $run_id ): ?array {
$state = self::state();
$run = $state['runs'][ $run_id ] ?? null;
return is_array( $run ) ? self::normalize_run( $run ) : null;
}

/**
* Request cancellation of a stored chat run.
*
* @param string $run_id Run ID.
* @return array<string,mixed>|null Normalized run, or null when absent.
*/
public static function request_cancel( string $run_id ): ?array {
$state = self::state();
if ( ! isset( $state['runs'][ $run_id ] ) ) {
return null;
}

$run = $state['runs'][ $run_id ];
$terminal = in_array( self::normalize_status( $run['status'] ?? '' ), array( self::STATUS_COMPLETED, self::STATUS_FAILED, self::STATUS_CANCELLED ), true );
$run['status'] = $terminal ? self::normalize_status( $run['status'] ?? '' ) : self::STATUS_CANCELLING;
$run['cancelled'] = ! $terminal;
$run['updated_at'] = self::now();

$state['runs'][ $run_id ] = $run;
self::save_state( $state );

return self::normalize_run( $run );
}

/**
* Return a cancellation interrupt when one was requested for the run.
*
* @param string $run_id Run ID.
* @param string $session_id Session ID.
* @return array<string,mixed>|null Interrupt message or null.
*/
public static function cancellation_interrupt_for_run( string $run_id, string $session_id = '' ): ?array {
$run = self::get_run( $run_id );
if ( null === $run || self::STATUS_CANCELLING !== $run['status'] ) {
return null;
}

$resolved_session_id = '' !== $session_id ? $session_id : (string) $run['session_id'];

return self::cancellation_interrupt_message( $run_id, $resolved_session_id );
}

/**
* Queue a follow-up message for a chat session.
*
* @param array<string,mixed> $input Canonical queue input.
* @return array<string,mixed> Queue result.
*/
public static function queue_message( array $input ): array {
$session_id = trim( (string) ( $input['session_id'] ?? '' ) );
$run_id = trim( (string) ( $input['run_id'] ?? '' ) );
if ( '' === $session_id ) {
throw new \InvalidArgumentException( 'session_id must be a non-empty string' );
}

$queued_id = 'queued_' . str_replace( 'run_', '', self::generate_run_id() );
$item = array(
'queued_message_id' => $queued_id,
'session_id' => $session_id,
'run_id' => $run_id,
'agent' => sanitize_title( (string) ( $input['agent'] ?? '' ) ),
'message' => (string) ( $input['message'] ?? '' ),
'attachments' => is_array( $input['attachments'] ?? null ) ? $input['attachments'] : array(),
'client_context' => is_array( $input['client_context'] ?? null ) ? $input['client_context'] : array(),
'created_at' => self::now(),
);

$state = self::state();
$state['queues'][ $session_id ] = array_values( $state['queues'][ $session_id ] ?? array() );
$state['queues'][ $session_id ][] = $item;
$position = count( $state['queues'][ $session_id ] );
self::save_state( $state );

return self::normalize_run( array(
'run_id' => '' !== $run_id ? $run_id : self::generate_run_id(),
'session_id' => $session_id,
'status' => self::STATUS_QUEUED,
'updated_at' => self::now(),
'queued_message_id' => $queued_id,
'position' => $position,
) );
}

/**
* Claim queued messages for a session.
*
* @param string $session_id Session ID.
* @return array<int,array<string,mixed>> Queued items.
*/
public static function claim_queued_messages( string $session_id ): array {
$state = self::state();
$items = array_values( $state['queues'][ $session_id ] ?? array() );
unset( $state['queues'][ $session_id ] );
self::save_state( $state );
return array_filter( $items, 'is_array' );
}

/**
* Build the interrupt message shape consumed by WP_Agent_Conversation_Loop.
*
Expand Down Expand Up @@ -130,4 +292,24 @@ public static function cancellation_interrupt_message(
)
);
}

/** @return array{runs:array<string,array<string,mixed>>,queues:array<string,array<int,array<string,mixed>>>} */
private static function state(): array {
$state = function_exists( 'get_option' ) ? get_option( self::OPTION_KEY, array() ) : array();
return array(
'runs' => is_array( $state['runs'] ?? null ) ? $state['runs'] : array(),
'queues' => is_array( $state['queues'] ?? null ) ? $state['queues'] : array(),
);
}

/** @param array<string,mixed> $state State to persist. */
private static function save_state( array $state ): void {
if ( function_exists( 'update_option' ) ) {
update_option( self::OPTION_KEY, $state, false );
}
}

private static function now(): string {
return gmdate( 'c' );
}
}
Loading
Loading