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
79 changes: 67 additions & 12 deletions src/Channels/register-agents-chat-jsonrpc-route.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* Exposes the canonical agents/chat ability over a JSON-RPC 2.0 wire keyed by
* agent id, so protocol clients that speak `message/send` (request/response)
* and `message/stream` (Server-Sent Events) can drive a registered runtime.
* Legacy Agent Protocol task method names (`tasks/send`, `tasks/sendSubscribe`)
* are accepted as aliases for compatibility with older clients.
*
* The route is intentionally a thin envelope: `message/send` is one synchronous
* agents/chat call wrapped in a Task; `message/stream` emits the same Task over
Expand All @@ -30,11 +32,13 @@

defined( 'ABSPATH' ) || exit;

const AGENTS_CHAT_JSONRPC_NAMESPACE = 'agents-api/v1';
const AGENTS_CHAT_JSONRPC_ROUTE = '/agent/(?P<agent_id>[A-Za-z0-9._-]+)';
const AGENTS_CHAT_JSONRPC_VERSION = '2.0';
const AGENTS_CHAT_JSONRPC_METHOD_SEND = 'message/send';
const AGENTS_CHAT_JSONRPC_METHOD_STREAM = 'message/stream';
const AGENTS_CHAT_JSONRPC_NAMESPACE = 'agents-api/v1';
const AGENTS_CHAT_JSONRPC_ROUTE = '/agent/(?P<agent_id>[A-Za-z0-9._-]+)';
const AGENTS_CHAT_JSONRPC_VERSION = '2.0';
const AGENTS_CHAT_JSONRPC_METHOD_SEND = 'message/send';
const AGENTS_CHAT_JSONRPC_METHOD_STREAM = 'message/stream';
const AGENTS_CHAT_JSONRPC_METHOD_TASKS_SEND = 'tasks/send';
const AGENTS_CHAT_JSONRPC_METHOD_TASKS_SEND_SUBSCRIBE = 'tasks/sendSubscribe';

// JSON-RPC 2.0 reserved error codes (see the spec + agenttic-client ErrorCodes).
const AGENTS_CHAT_JSONRPC_ERR_PARSE = -32700;
Expand Down Expand Up @@ -167,20 +171,20 @@ function agents_chat_jsonrpc_dispatch( \WP_REST_Request $request ): \WP_REST_Res
);
}

$input = agents_chat_jsonrpc_input_from_params( $params, $agent );
$input = agents_chat_jsonrpc_input_from_params( $params, $agent, $body );
if ( is_wp_error( $input ) ) {
return rest_ensure_response(
agents_chat_jsonrpc_error_frame( $rpc_id, AGENTS_CHAT_JSONRPC_ERR_INVALID_PARAMS, $input->get_error_message() )
);
}

if ( AGENTS_CHAT_JSONRPC_METHOD_STREAM === $method ) {
if ( agents_chat_jsonrpc_method_streams( $method ) ) {
// Streams directly and exits; never returns to the REST server.
agents_chat_jsonrpc_stream( $rpc_id, $input );
exit;
}

if ( AGENTS_CHAT_JSONRPC_METHOD_SEND !== $method ) {
if ( ! agents_chat_jsonrpc_method_sends( $method ) ) {
return rest_ensure_response(
agents_chat_jsonrpc_error_frame( $rpc_id, AGENTS_CHAT_JSONRPC_ERR_METHOD_NOT_FOUND, sprintf( 'Unknown JSON-RPC method "%s".', $method ) )
);
Expand All @@ -198,6 +202,24 @@ function agents_chat_jsonrpc_dispatch( \WP_REST_Request $request ): \WP_REST_Res
);
}

/**
* Whether a JSON-RPC method maps to a synchronous send turn.
*
* @param string $method JSON-RPC method.
*/
function agents_chat_jsonrpc_method_sends( string $method ): bool {
return in_array( $method, array( AGENTS_CHAT_JSONRPC_METHOD_SEND, AGENTS_CHAT_JSONRPC_METHOD_TASKS_SEND ), true );
}

/**
* Whether a JSON-RPC method maps to a streaming turn.
*
* @param string $method JSON-RPC method.
*/
function agents_chat_jsonrpc_method_streams( string $method ): bool {
return in_array( $method, array( AGENTS_CHAT_JSONRPC_METHOD_STREAM, AGENTS_CHAT_JSONRPC_METHOD_TASKS_SEND_SUBSCRIBE ), true );
}

/**
* Run one synchronous agents/chat turn.
*
Expand Down Expand Up @@ -280,9 +302,10 @@ function agents_chat_jsonrpc_stream( $rpc_id, array $input ): void {
*
* @param array<mixed> $params JSON-RPC params (MessageSendParams).
* @param string $agent Agent slug from the URL.
* @param array<mixed> $body Full JSON-RPC request body.
* @return array<string,mixed>|\WP_Error
*/
function agents_chat_jsonrpc_input_from_params( array $params, string $agent ) {
function agents_chat_jsonrpc_input_from_params( array $params, string $agent, array $body = array() ) {
if ( '' === $agent ) {
return new \WP_Error( 'agents_chat_jsonrpc_invalid_params', 'A non-empty agent id is required.' );
}
Expand All @@ -296,9 +319,13 @@ function agents_chat_jsonrpc_input_from_params( array $params, string $agent ) {
$session_id = \AgentsAPI\AI\agents_api_scalar_to_string( $params['sessionId'] ?? null );
$run_id = \AgentsAPI\AI\agents_api_scalar_to_string( $params['id'] ?? null );

$client_context = array(
'source' => 'jsonrpc',
'client_name' => 'jsonrpc-chat',
$client_context = agents_chat_jsonrpc_client_context( $message );
$client_context = array_merge(
$client_context,
array(
'source' => 'jsonrpc',
'client_name' => 'jsonrpc-chat',
)
);
if ( isset( $params['metadata'] ) && is_array( $params['metadata'] ) ) {
$client_context['metadata'] = $params['metadata'];
Expand All @@ -313,6 +340,10 @@ function agents_chat_jsonrpc_input_from_params( array $params, string $agent ) {
'client_context' => $client_context,
);

if ( array_key_exists( 'tokenStreaming', $body ) ) {
$input['token_streaming'] = (bool) $body['tokenStreaming'];
}

/**
* Filter the canonical agents/chat input built by the JSON-RPC adapter.
*
Expand All @@ -326,6 +357,30 @@ function agents_chat_jsonrpc_input_from_params( array $params, string $agent ) {
return is_array( $filtered ) ? \AgentsAPI\AI\agents_api_string_keyed_array( $filtered ) : $input;
}

/**
* Extract client context from Agent Protocol data parts.
*
* @param array<mixed> $message JSON-RPC Message.
* @return array<string,mixed>
*/
function agents_chat_jsonrpc_client_context( array $message ): array {
$parts = isset( $message['parts'] ) && is_array( $message['parts'] ) ? $message['parts'] : array();
$client_context = array();

foreach ( $parts as $part ) {
if ( ! is_array( $part ) || 'data' !== ( $part['type'] ?? null ) ) {
continue;
}

$data = isset( $part['data'] ) && is_array( $part['data'] ) ? $part['data'] : array();
if ( isset( $data['clientContext'] ) && is_array( $data['clientContext'] ) ) {
$client_context = array_merge( $client_context, \AgentsAPI\AI\agents_api_string_keyed_array( $data['clientContext'] ) );
}
}

return $client_context;
}

/**
* Map canonical agents/chat output onto a JSON-RPC Task.
*
Expand Down
56 changes: 54 additions & 2 deletions tests/agents-chat-jsonrpc-route-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,13 @@ function wp_get_ability( string $name ) {
use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_task_from_output;
use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_delta_to_wire;
use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_delta_frame;
use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_dispatch;
use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_result_frame;
use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_error_frame;
use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_extract_text;
use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_client_context;
use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_method_sends;
use function AgentsAPI\AI\Channels\agents_chat_jsonrpc_method_streams;
use function AgentsAPI\AI\Channels\agents_chat_input_schema;
use function AgentsAPI\AI\Channels\register_chat_stream_handler;

Expand Down Expand Up @@ -161,19 +165,22 @@ function wp_get_ability( string $name ) {
array( 'type' => 'text', 'text' => 'Hello ' ),
array( 'type' => 'text', 'text' => 'world' ),
array( 'type' => 'text', 'text' => 'SECRET', 'contentType' => 'context' ),
array( 'type' => 'data', 'data' => array( 'clientContext' => array( 'traceId' => 'trace-1' ) ) ),
array( 'type' => 'file', 'file' => array( 'name' => 'a.png', 'mimeType' => 'image/png' ) ),
),
),
'metadata' => array( 'locale' => 'es' ),
'metadata' => array( 'locale' => 'es' ),
);

$input = agents_chat_jsonrpc_input_from_params( $params, 'support-agent' );
$input = agents_chat_jsonrpc_input_from_params( $params, 'support-agent', array( 'tokenStreaming' => true ) );
agents_api_smoke_assert_equals( false, $input instanceof WP_Error, 'input mapping succeeds', $failures, $passes );
agents_api_smoke_assert_equals( 'support-agent', $input['agent'] ?? null, 'input carries agent slug from URL', $failures, $passes );
agents_api_smoke_assert_equals( 'Hello world', $input['message'] ?? null, 'input concatenates text parts and skips context parts', $failures, $passes );
agents_api_smoke_assert_equals( 'sess-9', $input['session_id'] ?? null, 'input carries sessionId', $failures, $passes );
agents_api_smoke_assert_equals( 'rpc-1', $input['run_id'] ?? null, 'input maps JSON-RPC id to run_id', $failures, $passes );
agents_api_smoke_assert_equals( 'jsonrpc', $input['client_context']['source'] ?? null, 'input marks jsonrpc source', $failures, $passes );
agents_api_smoke_assert_equals( 'trace-1', $input['client_context']['traceId'] ?? null, 'input preserves camelCase clientContext', $failures, $passes );
agents_api_smoke_assert_equals( true, $input['token_streaming'] ?? null, 'input maps top-level tokenStreaming to token_streaming', $failures, $passes );
$input_schema = agents_chat_input_schema();
$source_enum = $input_schema['properties']['client_context']['properties']['source']['enum'] ?? array();
agents_api_smoke_assert_equals( true, in_array( $input['client_context']['source'] ?? null, $source_enum, true ), 'input source is accepted by agents/chat schema', $failures, $passes );
Expand All @@ -182,6 +189,51 @@ function wp_get_ability( string $name ) {
$empty = agents_chat_jsonrpc_input_from_params( array( 'message' => array( 'parts' => array() ) ), 'support-agent' );
agents_api_smoke_assert_equals( true, $empty instanceof WP_Error, 'input rejects empty message', $failures, $passes );

$context = agents_chat_jsonrpc_client_context(
array(
'parts' => array(
array( 'type' => 'data', 'data' => array( 'clientContext' => array( 'first' => 'a', 'shared' => 'old' ) ) ),
array( 'type' => 'data', 'data' => array( 'clientContext' => array( 'second' => 'b', 'shared' => 'new' ) ) ),
),
)
);
agents_api_smoke_assert_equals( array( 'first' => 'a', 'shared' => 'new', 'second' => 'b' ), $context, 'clientContext data parts merge in order', $failures, $passes );

$top_level_token_streaming = agents_chat_jsonrpc_input_from_params( $params, 'support-agent', array( 'tokenStreaming' => false ) );
agents_api_smoke_assert_equals( false, $top_level_token_streaming['token_streaming'] ?? null, 'input preserves false top-level tokenStreaming', $failures, $passes );

// --- Legacy Agent Protocol method aliases -----------------------------------
agents_api_smoke_assert_equals( true, agents_chat_jsonrpc_method_sends( 'message/send' ), 'message/send maps to sync send', $failures, $passes );
agents_api_smoke_assert_equals( true, agents_chat_jsonrpc_method_sends( 'tasks/send' ), 'tasks/send maps to sync send', $failures, $passes );
agents_api_smoke_assert_equals( true, agents_chat_jsonrpc_method_streams( 'message/stream' ), 'message/stream maps to stream', $failures, $passes );
agents_api_smoke_assert_equals( true, agents_chat_jsonrpc_method_streams( 'tasks/sendSubscribe' ), 'tasks/sendSubscribe maps to stream', $failures, $passes );

$GLOBALS['__agents_api_smoke_abilities']['agents/chat'] = new class() {
public function execute( array $in ): array {
$GLOBALS['__agents_api_smoke_jsonrpc_last_input'] = $in;
return array(
'session_id' => $in['session_id'] ?? '',
'reply' => 'alias ok',
'run_id' => $in['run_id'] ?? '',
'completed' => true,
);
}
};
$alias_response = agents_chat_jsonrpc_dispatch(
new WP_REST_Request(
array( 'agent_id' => 'support-agent' ),
array(
'jsonrpc' => '2.0',
'id' => 'rpc-alias',
'method' => 'tasks/send',
'params' => array_merge( $params, array( 'id' => 'rpc-alias' ) ),
)
)
);
agents_api_smoke_assert_equals( 'rpc-alias', $alias_response->data['id'] ?? null, 'tasks/send dispatch echoes rpc id', $failures, $passes );
agents_api_smoke_assert_equals( 'alias ok', $alias_response->data['result']['status']['message']['parts'][0]['text'] ?? null, 'tasks/send dispatch runs sync handler', $failures, $passes );
agents_api_smoke_assert_equals( 'trace-1', $GLOBALS['__agents_api_smoke_jsonrpc_last_input']['client_context']['traceId'] ?? null, 'tasks/send dispatch preserves clientContext', $failures, $passes );

// extract_text directly
agents_api_smoke_assert_equals( 'ab', agents_chat_jsonrpc_extract_text( array( 'parts' => array( array( 'type' => 'text', 'text' => 'a' ), array( 'type' => 'text', 'text' => 'b' ) ) ) ), 'extract_text concatenates', $failures, $passes );

Expand Down