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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ wp_register_agent(
- `AgentsAPI\AI\Channels\WP_Agent_Message_Idempotency_Store`
- `AgentsAPI\AI\Channels\WP_Agent_Transient_Message_Idempotency_Store`
- `AgentsAPI\AI\Channels\WP_Agent_Message_Idempotency`
- `AgentsAPI\AI\Channels\WP_Agent_Bridge_Client`
- `AgentsAPI\AI\Channels\WP_Agent_Bridge_Queue_Item`
- `AgentsAPI\AI\Channels\WP_Agent_Bridge_Store`
- `AgentsAPI\AI\Channels\WP_Agent_Option_Bridge_Store`
- `AgentsAPI\AI\Channels\WP_Agent_Bridge`
- `AgentsAPI\AI\Tools\WP_Agent_Tool_Declaration`
- `AgentsAPI\AI\Tools\WP_Agent_Tool_Call`
- `AgentsAPI\AI\Tools\WP_Agent_Tool_Source_Registry`
Expand Down
5 changes: 5 additions & 0 deletions agents-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@
require_once AGENTS_API_PATH . 'src/Channels/class-wp-agent-message-idempotency-store.php';
require_once AGENTS_API_PATH . 'src/Channels/class-wp-agent-transient-message-idempotency-store.php';
require_once AGENTS_API_PATH . 'src/Channels/class-wp-agent-message-idempotency.php';
require_once AGENTS_API_PATH . 'src/Channels/class-wp-agent-bridge-client.php';
require_once AGENTS_API_PATH . 'src/Channels/class-wp-agent-bridge-queue-item.php';
require_once AGENTS_API_PATH . 'src/Channels/class-wp-agent-bridge-store.php';
require_once AGENTS_API_PATH . 'src/Channels/class-wp-agent-option-bridge-store.php';
require_once AGENTS_API_PATH . 'src/Channels/class-wp-agent-bridge.php';
require_once AGENTS_API_PATH . 'src/Channels/class-wp-agent-channel.php';
require_once AGENTS_API_PATH . 'src/Channels/register-agents-chat-ability.php';
require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-bindings.php';
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"php tests/conversation-loop-budgets-smoke.php",
"php tests/channels-smoke.php",
"php tests/webhook-safety-smoke.php",
"php tests/remote-bridge-smoke.php",
"php tests/context-authority-smoke.php",
"php tests/guidelines-substrate-smoke.php",
"php tests/workflow-bindings-smoke.php",
Expand Down
25 changes: 25 additions & 0 deletions docs/remote-bridge-protocol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Remote Bridge Protocol

Remote bridge clients are out-of-process processes that relay messages between an external surface and a WordPress agent runtime. They differ from direct `WP_Agent_Channel` subclasses because the client may be offline, webhook delivery may fail, and replies need a queue-first recovery path.

Agents API provides the generic PHP primitives for that protocol. It does not ship platform-specific clients, REST routes, authentication policy, or a chat runtime.

## Relationship To Core Connectors

Bridge clients may include a `connector_id`. When the Core Connectors API is available, `WP_Agent_Bridge_Client::connector()` resolves that id through `wp_get_connector()`.

Connectors own product/service identity and settings metadata. Agents API stores only bridge runtime state that Connectors does not model: callback URL, opaque bridge context, pending queue items, and acknowledgements.

## Flow

1. A consumer registers a remote bridge client with `WP_Agent_Bridge::register_client()`.
2. The consumer executes inbound chat turns through the canonical `agents/chat` ability.
3. The consumer queues outbound bridge replies with `WP_Agent_Bridge::enqueue()`.
4. A remote bridge polls `WP_Agent_Bridge::pending()` when webhook delivery is unavailable or failed.
5. The remote bridge acknowledges accepted items with `WP_Agent_Bridge::ack()`.

Queued items remain pending until acknowledged. Best-effort webhook delivery must not delete an item; `ack()` is the removal boundary.

## Storage

The default store is option-backed and intentionally small. Hosts that need custom tables, external queues, leases, or retention policies can replace it with `WP_Agent_Bridge::set_store()`.
126 changes: 126 additions & 0 deletions src/Channels/class-wp-agent-bridge-client.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php
/**
* Remote bridge client registration value object.
*
* @package AgentsAPI
* @since 0.103.0
*/

namespace AgentsAPI\AI\Channels;

use InvalidArgumentException;

defined( 'ABSPATH' ) || exit;

final class WP_Agent_Bridge_Client {

public readonly string $client_id;
public readonly ?string $connector_id;
public readonly ?string $callback_url;
/** @var array<string,mixed> */
public readonly array $context;
public readonly string $registered_at;

/**
* @param string $client_id Stable remote bridge client id.
* @param string|null $connector_id Optional Core Connectors API connector id.
* @param string|null $callback_url Optional callback URL for best-effort delivery.
* @param array<string,mixed> $context Opaque client metadata.
* @param string|null $registered_at Registration timestamp.
*/
public function __construct( string $client_id, ?string $connector_id = null, ?string $callback_url = null, array $context = array(), ?string $registered_at = null ) {
$this->client_id = self::normalize_required_id( $client_id, 'client_id' );
$this->connector_id = self::normalize_optional_id( $connector_id );
$this->callback_url = self::normalize_callback_url( $callback_url );
$this->context = $context;
$this->registered_at = $registered_at ?? gmdate( 'c' );
}

/**
* Build from an array payload.
*
* @param array<string,mixed> $data Client data.
* @return self
*/
public static function from_array( array $data ): self {
return new self(
(string) ( $data['client_id'] ?? '' ),
isset( $data['connector_id'] ) ? (string) $data['connector_id'] : null,
isset( $data['callback_url'] ) ? (string) $data['callback_url'] : null,
isset( $data['context'] ) && is_array( $data['context'] ) ? $data['context'] : array(),
isset( $data['registered_at'] ) ? (string) $data['registered_at'] : null
);
}

/**
* Export to a JSON-friendly array.
*
* @return array<string,mixed>
*/
public function to_array(): array {
return array(
'client_id' => $this->client_id,
'connector_id' => $this->connector_id,
'callback_url' => $this->callback_url,
'context' => $this->context,
'registered_at' => $this->registered_at,
);
}

/**
* Resolve Core Connectors metadata for this client when available.
*
* @return array<string,mixed>|null Connector metadata, or null when unavailable.
*/
public function connector(): ?array {
if ( null === $this->connector_id || ! function_exists( 'wp_get_connector' ) ) {
return null;
}

$connector = wp_get_connector( $this->connector_id );
return is_array( $connector ) ? $connector : null;
}

private static function normalize_required_id( string $value, string $field ): string {
$value = self::normalize_id( $value );
if ( '' === $value ) {
if ( 'client_id' === $field ) {
throw new InvalidArgumentException( 'client_id must be a non-empty slug.' );
}
throw new InvalidArgumentException( 'id must be a non-empty slug.' );
}
return $value;
}

private static function normalize_optional_id( ?string $value ): ?string {
if ( null === $value ) {
return null;
}

$value = self::normalize_id( $value );
return '' === $value ? null : $value;
}

private static function normalize_id( string $value ): string {
$value = trim( strtolower( str_replace( '_', '-', $value ) ) );
$value = preg_replace( '/[^a-z0-9-]+/', '-', $value );
return trim( (string) $value, '-' );
}

private static function normalize_callback_url( ?string $callback_url ): ?string {
if ( null === $callback_url ) {
return null;
}

$callback_url = trim( $callback_url );
if ( '' === $callback_url ) {
return null;
}

if ( false === filter_var( $callback_url, FILTER_VALIDATE_URL ) ) {
throw new InvalidArgumentException( 'callback_url must be a valid URL.' );
}

return $callback_url;
}
}
147 changes: 147 additions & 0 deletions src/Channels/class-wp-agent-bridge-queue-item.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php
/**
* Remote bridge queue item value object.
*
* @package AgentsAPI
* @since 0.103.0
*/

namespace AgentsAPI\AI\Channels;

use InvalidArgumentException;

defined( 'ABSPATH' ) || exit;

final class WP_Agent_Bridge_Queue_Item {

public readonly string $queue_id;
public readonly string $client_id;
public readonly ?string $connector_id;
public readonly string $agent;
public readonly ?string $session_id;
public readonly string $role;
public readonly string $content;
public readonly bool $completed;
public readonly string $created_at;
public readonly string $delivery_status;
/** @var array<string,mixed> */
public readonly array $metadata;

/**
* @param array<string,mixed> $args Queue item fields.
*/
public function __construct( array $args ) {
$this->queue_id = self::normalize_required_string( (string) ( $args['queue_id'] ?? self::new_queue_id() ), 'queue_id' );
$this->client_id = self::normalize_required_slug( (string) ( $args['client_id'] ?? '' ), 'client_id' );
$this->connector_id = isset( $args['connector_id'] ) ? self::normalize_optional_slug( (string) $args['connector_id'] ) : null;
$this->agent = self::normalize_required_slug( (string) ( $args['agent'] ?? '' ), 'agent' );
$this->session_id = isset( $args['session_id'] ) ? self::normalize_optional_string( (string) $args['session_id'] ) : null;
$this->role = self::normalize_role( (string) ( $args['role'] ?? 'assistant' ) );
$this->content = self::normalize_required_string( (string) ( $args['content'] ?? '' ), 'content' );
$this->completed = (bool) ( $args['completed'] ?? true );
$this->created_at = isset( $args['created_at'] ) ? (string) $args['created_at'] : gmdate( 'c' );
$this->delivery_status = self::normalize_delivery_status( (string) ( $args['delivery_status'] ?? 'pending' ) );
$this->metadata = isset( $args['metadata'] ) && is_array( $args['metadata'] ) ? $args['metadata'] : array();
}

/**
* Build a queue item from a stored array.
*
* @param array<string,mixed> $data Stored data.
* @return self
*/
public static function from_array( array $data ): self {
return new self( $data );
}

/**
* Export to a JSON-friendly array.
*
* @return array<string,mixed>
*/
public function to_array(): array {
return array(
'queue_id' => $this->queue_id,
'client_id' => $this->client_id,
'connector_id' => $this->connector_id,
'agent' => $this->agent,
'session_id' => $this->session_id,
'role' => $this->role,
'content' => $this->content,
'completed' => $this->completed,
'created_at' => $this->created_at,
'delivery_status' => $this->delivery_status,
'metadata' => $this->metadata,
);
}

/**
* Return a copy with an updated delivery status.
*
* @param string $delivery_status Delivery status.
* @return self
*/
public function with_delivery_status( string $delivery_status ): self {
$data = $this->to_array();
$data['delivery_status'] = $delivery_status;
return new self( $data );
}

private static function new_queue_id(): string {
return 'bridge_' . bin2hex( random_bytes( 16 ) );
}

private static function normalize_required_slug( string $value, string $field ): string {
$value = self::normalize_slug( $value );
if ( '' === $value ) {
if ( 'client_id' === $field ) {
throw new InvalidArgumentException( 'client_id must be a non-empty slug.' );
}
throw new InvalidArgumentException( 'agent must be a non-empty slug.' );
}
return $value;
}

private static function normalize_optional_slug( string $value ): ?string {
$value = self::normalize_slug( $value );
return '' === $value ? null : $value;
}

private static function normalize_slug( string $value ): string {
$value = trim( strtolower( str_replace( '_', '-', $value ) ) );
$value = preg_replace( '/[^a-z0-9-]+/', '-', $value );
return trim( (string) $value, '-' );
}

private static function normalize_required_string( string $value, string $field ): string {
$value = trim( $value );
if ( '' === $value ) {
if ( 'queue_id' === $field ) {
throw new InvalidArgumentException( 'queue_id must be non-empty.' );
}
throw new InvalidArgumentException( 'content must be non-empty.' );
}
return $value;
}

private static function normalize_optional_string( string $value ): ?string {
$value = trim( $value );
return '' === $value ? null : $value;
}

private static function normalize_role( string $role ): string {
$role = self::normalize_slug( $role );
if ( ! in_array( $role, array( 'assistant', 'system', 'tool' ), true ) ) {
throw new InvalidArgumentException( 'role must be assistant, system, or tool.' );
}
return $role;
}

private static function normalize_delivery_status( string $delivery_status ): string {
$delivery_status = self::normalize_slug( $delivery_status );
if ( ! in_array( $delivery_status, array( 'pending', 'delivered', 'failed' ), true ) ) {
throw new InvalidArgumentException( 'delivery_status must be pending, delivered, or failed.' );
}
return $delivery_status;
}
}
39 changes: 39 additions & 0 deletions src/Channels/class-wp-agent-bridge-store.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php
/**
* Remote bridge store contract.
*
* @package AgentsAPI
* @since 0.103.0
*/

namespace AgentsAPI\AI\Channels;

defined( 'ABSPATH' ) || exit;

interface WP_Agent_Bridge_Store {

public function register_client( WP_Agent_Bridge_Client $client ): void;

public function get_client( string $client_id ): ?WP_Agent_Bridge_Client;

public function enqueue( WP_Agent_Bridge_Queue_Item $item ): WP_Agent_Bridge_Queue_Item;

/**
* Return pending queue items for a bridge client.
*
* @param string $client_id Bridge client id.
* @param int $limit Maximum items to return.
* @param string[] $session_ids Optional session id filter.
* @return WP_Agent_Bridge_Queue_Item[] Pending items.
*/
public function pending( string $client_id, int $limit = 25, array $session_ids = array() ): array;

/**
* Acknowledge accepted queue items and remove them from pending delivery.
*
* @param string $client_id Bridge client id.
* @param string[] $queue_ids Queue ids to acknowledge.
* @return int Number of acknowledged items.
*/
public function ack( string $client_id, array $queue_ids ): int;
}
Loading
Loading