diff --git a/README.md b/README.md index 27e16d8..4580cf8 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,23 @@ $visible_tools = ( new WP_Agent_Tool_Policy() )->resolve( ); ``` +Consumers that gather tools from multiple product-owned sources can use `WP_Agent_Tool_Source_Registry` before the policy pass. Sources are named callbacks that receive `(array $context, WP_Agent_Tool_Source_Registry $registry)` and return declarations keyed by tool name. Lower source priorities run earlier, `agents_api_tool_source_order` can reorder sources for runtime context such as modes, and earlier sources win duplicate tool names. + +```php +$registry = new WP_Agent_Tool_Source_Registry(); +$registry->registerSource( 'runtime', $runtime_source, 10 ); +$registry->registerSource( 'static', $static_source, 20 ); + +$all_tools = $registry->gather( + array( + 'agent_id' => 'writer', + 'modes' => array( 'chat' ), + ) +); +``` + +The registry exposes three composition hooks: `agents_api_tool_sources` for injecting or replacing named sources, `agents_api_tool_source_order` for source precedence, and `agents_api_tool_source_tools` for context-aware adjustment of one source's gathered declarations. + Action policy is resolved by `WP_Agent_Action_Policy_Resolver` and always returns one of the canonical values from `AgentsAPI\AI\Tools\WP_Agent_Action_Policy`: `direct`, `preview`, or `forbidden`. Resolution order is: diff --git a/composer.json b/composer.json index d06ab88..a7ff72a 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "php tests/action-policy-values-smoke.php", "php tests/consent-policy-smoke.php", "php tests/tool-policy-contracts-smoke.php", + "php tests/tool-source-registry-smoke.php", "php tests/tool-tier-resolver-smoke.php", "php tests/action-policy-resolver-smoke.php", "php tests/ability-meta-abilities-smoke.php", diff --git a/src/Tools/class-wp-agent-tool-source-registry.php b/src/Tools/class-wp-agent-tool-source-registry.php index 880ee38..72227e2 100644 --- a/src/Tools/class-wp-agent-tool-source-registry.php +++ b/src/Tools/class-wp-agent-tool-source-registry.php @@ -15,10 +15,17 @@ class WP_Agent_Tool_Source_Registry { /** - * @var array + * @var array */ private array $sources = array(); + /** + * Monotonic registration index used to keep same-priority ordering stable. + * + * @var int + */ + private int $registration_index = 0; + /** * Register a source callback. * @@ -27,14 +34,19 @@ class WP_Agent_Tool_Source_Registry { * * @param string $source_slug Source slug. * @param callable $source Source callback. + * @param int $priority Source priority. Lower numbers run earlier. * @return void */ - public function registerSource( string $source_slug, callable $source ): void { + public function registerSource( string $source_slug, callable $source, int $priority = 10 ): void { if ( '' === $source_slug ) { throw new \InvalidArgumentException( 'invalid_tool_source: source_slug must be a non-empty string' ); } - $this->sources[ $source_slug ] = $source; + $this->sources[ $source_slug ] = array( + 'callback' => $source, + 'priority' => $priority, + 'index' => $this->registration_index++, + ); } /** @@ -54,7 +66,7 @@ public function unregisterSource( string $source_slug ): void { * @return array */ public function getSources( array $context = array() ): array { - $sources = $this->sources; + $sources = $this->getRegisteredSources(); if ( function_exists( 'apply_filters' ) ) { $sources = apply_filters( 'agents_api_tool_sources', $sources, $context, $this ); } @@ -73,15 +85,7 @@ public function getSources( array $context = array() ): array { public function gather( array $context = array() ): array { $tools = array(); $sources = $this->getSources( $context ); - $order = array_keys( $sources ); - - if ( function_exists( 'apply_filters' ) ) { - $order = apply_filters( 'agents_api_tool_source_order', $order, $context, $this ); - } - - if ( ! is_array( $order ) ) { - return array(); - } + $order = $this->getSourceOrder( $sources, $context ); foreach ( $order as $source_slug ) { if ( ! is_string( $source_slug ) || ! isset( $sources[ $source_slug ] ) ) { @@ -89,6 +93,16 @@ public function gather( array $context = array() ): array { } $source_tools = call_user_func( $sources[ $source_slug ], $context, $this ); + if ( function_exists( 'apply_filters' ) ) { + $source_tools = apply_filters( + 'agents_api_tool_source_tools', + $source_tools, + $source_slug, + $context, + $this + ); + } + if ( ! is_array( $source_tools ) ) { continue; } @@ -105,6 +119,60 @@ public function gather( array $context = array() ): array { return $tools; } + /** + * Return directly registered sources sorted by priority and registration order. + * + * @return array + */ + private function getRegisteredSources(): array { + $sources = $this->sources; + uasort( + $sources, + static function ( array $a, array $b ): int { + return ( $a['priority'] <=> $b['priority'] ) ?: ( $a['index'] <=> $b['index'] ); + } + ); + + $callbacks = array(); + foreach ( $sources as $source_slug => $source ) { + $callbacks[ $source_slug ] = $source['callback']; + } + + return $callbacks; + } + + /** + * Return source slugs in final precedence order. + * + * @param array $sources Registered sources. + * @param array $context Runtime context. + * @return array + */ + private function getSourceOrder( array $sources, array $context ): array { + $order = array_keys( $sources ); + + if ( function_exists( 'apply_filters' ) ) { + $order = apply_filters( + 'agents_api_tool_source_order', + $order, + $context, + $this, + $sources + ); + } + + if ( ! is_array( $order ) ) { + return array(); + } + + return array_values( + array_filter( + $order, + static fn( $source_slug ): bool => is_string( $source_slug ) && isset( $sources[ $source_slug ] ) + ) + ); + } + /** * Normalize source metadata on a gathered declaration. * diff --git a/tests/tool-source-registry-smoke.php b/tests/tool-source-registry-smoke.php new file mode 100644 index 0000000..9b4b37d --- /dev/null +++ b/tests/tool-source-registry-smoke.php @@ -0,0 +1,256 @@ +registerSource( + 'late', + static fn(): array => array( + 'agent/shared' => array( + 'description' => 'Late declaration.', + ), + ), + 20 +); +$priority_registry->registerSource( + 'early', + static fn(): array => array( + 'agent/shared' => array( + 'description' => 'Early declaration.', + ), + ), + 10 +); +$priority_tools = $priority_registry->gather(); +agents_api_smoke_assert_equals( + 'early', + $priority_tools['agent/shared']['source'] ?? null, + 'source priority controls default duplicate precedence', + $failures, + $passes +); + +$seen_contexts = array(); +$registry = new AgentsAPI\AI\Tools\WP_Agent_Tool_Source_Registry(); + +$registry->registerSource( + 'static', + static function ( array $context ) use ( &$seen_contexts ): array { + $seen_contexts['static'] = $context; + + return array( + 'agent/shared' => array( + 'description' => 'Static shared declaration.', + ), + 'static/only' => array( + 'description' => 'Static-only declaration.', + ), + ); + }, + 30 +); + +$registry->registerSource( + 'adjacent', + static function ( array $context ): array { + $modes = is_array( $context['modes'] ?? null ) ? $context['modes'] : array(); + if ( ! in_array( 'pipeline', $modes, true ) ) { + return array(); + } + + return array( + 'agent/shared' => array( + 'description' => 'Pipeline-adjacent declaration.', + ), + 'adjacent/only' => array( + 'description' => 'Adjacent-only declaration.', + ), + ); + }, + 20 +); + +$registry->registerSource( + 'runtime', + static function ( + array $context, + AgentsAPI\AI\Tools\WP_Agent_Tool_Source_Registry $source_registry + ) use ( $registry ): array { + agents_api_smoke_assert_equals( + $registry, + $source_registry, + 'source callbacks receive the active registry', + $GLOBALS['failures'], + $GLOBALS['passes'] + ); + + return array( + 'agent/shared' => array( + 'name' => 'agent/shared', + 'source' => 'runtime', + 'description' => 'Runtime declaration.', + ), + 'runtime/only' => array( + 'description' => 'Runtime-only declaration.', + ), + ); + }, + 10 +); + +add_filter( + 'agents_api_tool_sources', + static function ( array $sources, array $context ): array { + if ( 'writer' !== ( $context['agent_id'] ?? '' ) ) { + return $sources; + } + + $sources['filtered'] = static function (): array { + return array( + 'filtered/only' => array( + 'description' => 'Filter-injected declaration.', + ), + ); + }; + + return $sources; + }, + 10, + 2 +); + +add_filter( + 'agents_api_tool_source_order', + static function ( + array $order, + array $context, + AgentsAPI\AI\Tools\WP_Agent_Tool_Source_Registry $source_registry, + array $sources + ) use ( $registry ): array { + agents_api_smoke_assert_equals( + $registry, + $source_registry, + 'source order filters receive the active registry', + $GLOBALS['failures'], + $GLOBALS['passes'] + ); + agents_api_smoke_assert_equals( + true, + isset( $sources['runtime'], $sources['static'] ), + 'source order filters receive registered sources', + $GLOBALS['failures'], + $GLOBALS['passes'] + ); + + $modes = is_array( $context['modes'] ?? null ) ? $context['modes'] : array(); + if ( in_array( 'pipeline', $modes, true ) ) { + return array( 'adjacent', 'runtime', 'filtered', 'static' ); + } + + return $order; + }, + 10, + 4 +); + +add_filter( + 'agents_api_tool_source_tools', + static function ( $source_tools, string $source_slug, array $context ) { + if ( + 'runtime' === $source_slug + && 'writer' === ( $context['agent_id'] ?? '' ) + && is_array( $source_tools ) + ) { + $source_tools['runtime/filtered'] = array( + 'description' => 'Filtered runtime declaration.', + ); + } + + return $source_tools; + }, + 10, + 3 +); + +$tools = $registry->gather( + array( + 'agent_id' => 'writer', + 'modes' => array( 'pipeline' ), + ) +); + +agents_api_smoke_assert_equals( + array( + 'agent/shared', + 'adjacent/only', + 'runtime/only', + 'runtime/filtered', + 'filtered/only', + 'static/only', + ), + array_keys( $tools ), + 'registry gathers multiple ordered sources with duplicate-name precedence', + $failures, + $passes +); +agents_api_smoke_assert_equals( + 'adjacent', + $tools['agent/shared']['source'], + 'earlier ordered source wins duplicate tool names', + $failures, + $passes +); +agents_api_smoke_assert_equals( + 'static/only', + $tools['static/only']['name'], + 'registry normalizes missing tool name from array key', + $failures, + $passes +); +agents_api_smoke_assert_equals( + array(), + $tools['static/only']['parameters'], + 'registry normalizes missing parameters to an array', + $failures, + $passes +); +agents_api_smoke_assert_equals( + array( 'pipeline' ), + $seen_contexts['static']['modes'] ?? null, + 'source callbacks receive runtime context including modes', + $failures, + $passes +); +agents_api_smoke_assert_equals( + true, + isset( $tools['filtered/only'] ), + 'source filter can inject product/runtime sources', + $failures, + $passes +); +agents_api_smoke_assert_equals( + true, + isset( $tools['runtime/filtered'] ), + 'source tools filter can adjust gathered declarations per context', + $failures, + $passes +); + +agents_api_smoke_finish( 'Agents API tool source registry', $failures, $passes );