From b00f8d47012bf3c867dc5a0210914e5aec2b8eb3 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Wed, 10 Jun 2026 08:26:41 -0300 Subject: [PATCH] Channels: per-agent routing for register_chat_handler() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit register_chat_handler() registers a wp_agent_chat_handler filter that claims EVERY turn not already handled, ignoring the agent slug. On a site with two consumer plugins the first registration wins for all agents, so the second consumer's agent never reaches its runtime — consumers have to bypass the helper and hand-roll a slug-checking filter. Add an optional $agent_slug parameter: when set, the handler claims only turns whose `agent` matches and passes every other agent through to later handlers, so multiple runtimes coexist. Default (null) keeps the original behavior, so this is backward compatible. Adds smoke coverage: two slug-scoped handlers each claim their own agent, and an unmatched agent passes through to the no-handler path. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/external-clients.md | 3 +++ src/Channels/register-agents-chat-ability.php | 24 +++++++++++++------ tests/agents-chat-ability-smoke.php | 19 +++++++++++++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/docs/external-clients.md b/docs/external-clients.md index 9ef92ec..e256ffe 100644 --- a/docs/external-clients.md +++ b/docs/external-clients.md @@ -174,6 +174,9 @@ streaming handlers are consumer territory (the same boundary as `agents/chat`). add_filter( 'agents_api_enable_default_conversation_store', '__return_true' ); // 2. A runtime: register a chat handler (sync) and/or a stream handler (tokens). +// Pass an agent slug as the 3rd arg to scope the handler to one agent so +// multiple consumer plugins can coexist on the same site without colliding: +// register_chat_handler( $my_sync_handler, 10, 'my-agent-slug' ); AgentsAPI\AI\Channels\register_chat_handler( $my_sync_handler ); AgentsAPI\AI\Channels\register_chat_stream_handler( $my_stream_handler ); diff --git a/src/Channels/register-agents-chat-ability.php b/src/Channels/register-agents-chat-ability.php index 65cf10f..fa6740e 100644 --- a/src/Channels/register-agents-chat-ability.php +++ b/src/Channels/register-agents-chat-ability.php @@ -550,19 +550,29 @@ function agents_chat_output_schema(): array { * Equivalent to `add_filter( 'wp_agent_chat_handler', ... )` but reads more * intentionally at the call site. * + * Pass an `$agent_slug` to scope the handler to a single agent: it then claims + * only turns whose `agent` matches and passes every other agent through to later + * handlers, so multiple consumer plugins can register runtimes on the same site + * without colliding. Without it (the default), the handler claims any turn not + * already handled — the original behavior, fine for a single-consumer site. + * * @since 0.103.0 * - * @param callable $handler Receives the canonical input array, returns the - * canonical output array or WP_Error. - * @param int $priority Filter priority. Default 10. + * @param callable $handler Receives the canonical input array, returns the + * canonical output array or WP_Error. + * @param int $priority Filter priority. Default 10. + * @param string|null $agent_slug Optional agent slug to scope this handler to. When + * set, non-matching agents are passed through. */ -function register_chat_handler( callable $handler, int $priority = 10 ): void { +function register_chat_handler( callable $handler, int $priority = 10, ?string $agent_slug = null ): void { add_filter( 'wp_agent_chat_handler', - static function ( $existing, array $input ) use ( $handler ) { - unset( $input ); + static function ( $existing, array $input ) use ( $handler, $agent_slug ) { if ( null !== $existing ) { - return $existing; + return $existing; // An earlier handler already claimed this turn. + } + if ( null !== $agent_slug && ( $input['agent'] ?? null ) !== $agent_slug ) { + return $existing; // Scoped handler: not our agent, pass through. } return $handler; }, diff --git a/tests/agents-chat-ability-smoke.php b/tests/agents-chat-ability-smoke.php index 72ca1a0..2aa6248 100644 --- a/tests/agents-chat-ability-smoke.php +++ b/tests/agents-chat-ability-smoke.php @@ -219,6 +219,25 @@ function smoke_assert( $expected, $actual, string $name, array &$failures, int & smoke_assert( true, $invalid_principal_result instanceof WP_Error, 'invalid_principal_returns_wp_error', $failures, $passes ); smoke_assert( 'agents_chat_invalid_principal', $invalid_principal_result->get_error_code(), 'invalid_principal_error_code', $failures, $passes ); +// 10. Per-agent handler routing: scoped handlers coexist and pass through. +$GLOBALS['__smoke_filters']['wp_agent_chat_handler'] = array(); +register_chat_handler( + static fn( array $i ) => array( 'session_id' => 'sa', 'reply' => 'from-alpha', 'completed' => true ), + 10, + 'alpha' +); +register_chat_handler( + static fn( array $i ) => array( 'session_id' => 'sb', 'reply' => 'from-beta', 'completed' => true ), + 10, + 'beta' +); +$alpha = agents_chat_dispatch( array( 'agent' => 'alpha', 'message' => 'hi' ) ); +$beta = agents_chat_dispatch( array( 'agent' => 'beta', 'message' => 'hi' ) ); +$gamma = agents_chat_dispatch( array( 'agent' => 'gamma', 'message' => 'hi' ) ); +smoke_assert( 'from-alpha', $alpha['reply'] ?? null, 'scoped_handler_claims_its_agent', $failures, $passes ); +smoke_assert( 'from-beta', $beta['reply'] ?? null, 'second_scoped_handler_coexists', $failures, $passes ); +smoke_assert( true, $gamma instanceof WP_Error, 'unmatched_agent_passes_through_to_no_handler', $failures, $passes ); + // ─── Done ─────────────────────────────────────────────────────────── if ( $failures ) {