From f3274b0ab64cf118f4fbd6dfe86ad0b7228a5169 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 7 Jun 2026 22:39:49 -0400 Subject: [PATCH] Add identity store materialization lifecycle --- agents-api.php | 1 + composer.json | 1 + .../class-wp-agent-identity-stores.php | 34 +++++ src/Registry/register-agents.php | 108 ++++++++++++++ .../identity-store-materialization-smoke.php | 134 ++++++++++++++++++ 5 files changed, 278 insertions(+) create mode 100644 src/Identity/class-wp-agent-identity-stores.php create mode 100644 tests/identity-store-materialization-smoke.php diff --git a/agents-api.php b/agents-api.php index 7d30364..a2643c8 100644 --- a/agents-api.php +++ b/agents-api.php @@ -88,6 +88,7 @@ require_once AGENTS_API_PATH . 'src/Identity/class-wp-agent-identity-scope.php'; require_once AGENTS_API_PATH . 'src/Identity/class-wp-agent-materialized-identity.php'; require_once AGENTS_API_PATH . 'src/Identity/class-wp-agent-identity-store.php'; +require_once AGENTS_API_PATH . 'src/Identity/class-wp-agent-identity-stores.php'; require_once AGENTS_API_PATH . 'src/Transcripts/class-wp-agent-conversation-store.php'; require_once AGENTS_API_PATH . 'src/Transcripts/class-wp-agent-principal-conversation-store.php'; require_once AGENTS_API_PATH . 'src/Transcripts/class-wp-agent-principal-conversation-session-reader.php'; diff --git a/composer.json b/composer.json index 32505c0..ffaa139 100644 --- a/composer.json +++ b/composer.json @@ -52,6 +52,7 @@ "php tests/approval-resolver-contract-smoke.php", "php tests/pending-action-abilities-smoke.php", "php tests/identity-smoke.php", + "php tests/identity-store-materialization-smoke.php", "php tests/memory-metadata-contract-smoke.php", "php tests/memory-store-resolver-smoke.php", "php tests/ability-lifecycle-bridge-smoke.php", diff --git a/src/Identity/class-wp-agent-identity-stores.php b/src/Identity/class-wp-agent-identity-stores.php new file mode 100644 index 0000000..da7377b --- /dev/null +++ b/src/Identity/class-wp-agent-identity-stores.php @@ -0,0 +1,34 @@ + $context Host-owned request context. + * @return WP_Agent_Identity_Store|null + */ + public static function get_store( array $context = array() ): ?WP_Agent_Identity_Store { + if ( isset( $context['identity_store'] ) && $context['identity_store'] instanceof WP_Agent_Identity_Store ) { + return $context['identity_store']; + } + + $store = function_exists( 'apply_filters' ) ? apply_filters( 'wp_agent_identity_store', null, $context ) : null; + return $store instanceof WP_Agent_Identity_Store ? $store : null; + } +} diff --git a/src/Registry/register-agents.php b/src/Registry/register-agents.php index 3d8d7a8..7fddc4b 100644 --- a/src/Registry/register-agents.php +++ b/src/Registry/register-agents.php @@ -111,6 +111,114 @@ function wp_unregister_agent( string $slug ): ?WP_Agent { } } +if ( ! function_exists( 'wp_get_agent_identity_store' ) ) { + /** + * Resolves the host-provided materialized identity store. + * + * Hosts can pass a store with `$context['identity_store']` or provide one + * through the `wp_agent_identity_store` filter. Agents API does not choose a + * concrete storage backend. + * + * @param array $context Host-owned request context. + * @return \AgentsAPI\Core\Identity\WP_Agent_Identity_Store|null + */ + function wp_get_agent_identity_store( array $context = array() ): ?\AgentsAPI\Core\Identity\WP_Agent_Identity_Store { + return \AgentsAPI\Core\Identity\WP_Agent_Identity_Stores::get_store( $context ); + } +} + +if ( ! function_exists( 'wp_materialize_agent_identity' ) ) { + /** + * Materializes a registered agent definition through a host identity store. + * + * The identity scope is derived from the registered agent slug, owner user ID, + * and instance key. Callers may pass `owner_user_id` and `instance_key` in + * `$args`; otherwise owner defaults to the agent's resolver when present, then + * `0`, and instance defaults to `default`. + * + * @param string|WP_Agent $agent Agent slug or definition object. + * @param \AgentsAPI\Core\Identity\WP_Agent_Identity_Store|null $store Store, or null to use the resolver. + * @param array $args Host-owned materialization options and context. + * @return \AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity|null Materialized identity, or null without a registered agent/store. + */ + function wp_materialize_agent_identity( $agent, ?\AgentsAPI\Core\Identity\WP_Agent_Identity_Store $store = null, array $args = array() ): ?\AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity { + $agent = $agent instanceof WP_Agent ? $agent : wp_get_agent( (string) $agent ); + if ( ! $agent instanceof WP_Agent ) { + return null; + } + + $store = $store instanceof \AgentsAPI\Core\Identity\WP_Agent_Identity_Store ? $store : wp_get_agent_identity_store( $args ); + if ( ! $store instanceof \AgentsAPI\Core\Identity\WP_Agent_Identity_Store ) { + return null; + } + + $owner_user_id = wp_resolve_agent_identity_owner_user_id( $agent, $args ); + $instance_key = isset( $args['instance_key'] ) && is_scalar( $args['instance_key'] ) ? (string) $args['instance_key'] : 'default'; + $scope = new \AgentsAPI\Core\Identity\WP_Agent_Identity_Scope( $agent->get_slug(), $owner_user_id, $instance_key ); + $meta = $agent->get_meta(); + if ( isset( $args['meta'] ) && is_array( $args['meta'] ) ) { + foreach ( $args['meta'] as $key => $value ) { + if ( is_string( $key ) ) { + $meta[ $key ] = $value; + } + } + } + + return $store->materialize( $scope, $agent->get_default_config(), $meta ); + } +} + +if ( ! function_exists( 'wp_materialize_registered_agent_identities' ) ) { + /** + * Materializes all currently registered agents through a host identity store. + * + * @param \AgentsAPI\Core\Identity\WP_Agent_Identity_Store|null $store Store, or null to use the resolver. + * @param array $args Host-owned materialization options and context. + * @return array Identities keyed by registered agent slug. + */ + function wp_materialize_registered_agent_identities( ?\AgentsAPI\Core\Identity\WP_Agent_Identity_Store $store = null, array $args = array() ): array { + $store = $store instanceof \AgentsAPI\Core\Identity\WP_Agent_Identity_Store ? $store : wp_get_agent_identity_store( $args ); + if ( ! $store instanceof \AgentsAPI\Core\Identity\WP_Agent_Identity_Store ) { + return array(); + } + + $identities = array(); + foreach ( wp_get_agents() as $agent ) { + $identity = wp_materialize_agent_identity( $agent, $store, $args ); + if ( $identity instanceof \AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity ) { + $identities[ $agent->get_slug() ] = $identity; + } + } + + return $identities; + } +} + +if ( ! function_exists( 'wp_resolve_agent_identity_owner_user_id' ) ) { + /** + * Resolves the owner user ID for generic identity materialization. + * + * @param WP_Agent $agent Registered agent definition. + * @param array $args Materialization options. + * @return int Non-negative owner user ID. Zero means shared/no owner. + */ + function wp_resolve_agent_identity_owner_user_id( WP_Agent $agent, array $args = array() ): int { + if ( isset( $args['owner_user_id'] ) && is_numeric( $args['owner_user_id'] ) ) { + return max( 0, (int) $args['owner_user_id'] ); + } + + $owner_resolver = $agent->get_owner_resolver(); + if ( is_callable( $owner_resolver ) ) { + $owner_user_id = call_user_func( $owner_resolver ); + if ( is_numeric( $owner_user_id ) ) { + return max( 0, (int) $owner_user_id ); + } + } + + return 0; + } +} + if ( ! function_exists( 'wp_materialize_registered_agents' ) ) { /** * Materializes registered agents through a host-provided adapter. diff --git a/tests/identity-store-materialization-smoke.php b/tests/identity-store-materialization-smoke.php new file mode 100644 index 0000000..d51b6c4 --- /dev/null +++ b/tests/identity-store-materialization-smoke.php @@ -0,0 +1,134 @@ + */ + private array $identities = array(); + + private int $next_id = 1; + + public function resolve( AgentsAPI\Core\Identity\WP_Agent_Identity_Scope $scope ): ?AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity { + return $this->identities[ $scope->key() ] ?? null; + } + + public function get( int $identity_id ): ?AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity { + foreach ( $this->identities as $identity ) { + if ( $identity->id === $identity_id ) { + return $identity; + } + } + + return null; + } + + public function materialize( AgentsAPI\Core\Identity\WP_Agent_Identity_Scope $scope, array $default_config = array(), array $meta = array() ): AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity { + $normalized_scope = $scope->normalize(); + $key = $normalized_scope->key(); + + if ( isset( $this->identities[ $key ] ) ) { + return $this->identities[ $key ]; + } + + $identity = new AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity( $this->next_id++, $normalized_scope, $default_config, $meta, 10, 10 ); + $this->identities[ $key ] = $identity; + + return $identity; + } + + public function update( AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity $identity ): AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity { + $this->identities[ $identity->scope->key() ] = $identity; + return $identity; + } + + public function delete( AgentsAPI\Core\Identity\WP_Agent_Identity_Scope $scope ): bool { + unset( $this->identities[ $scope->key() ] ); + return true; + } +} + +$store = new Agents_API_Test_Identity_Store(); + +add_action( + 'wp_agents_api_init', + static function (): void { + wp_register_agent( + 'kitchen-agent', + array( + 'label' => 'Kitchen Agent', + 'default_config' => array( 'model' => 'gpt-test' ), + 'owner_resolver' => static fn() => 7, + 'meta' => array( + 'source_plugin' => 'example/example.php', + 'source_type' => 'bundled-agent', + 'source_package' => 'chef-pack', + 'source_version' => '1.0.0', + ), + ) + ); + + wp_register_agent( 'pantry-agent', array( 'label' => 'Pantry Agent' ) ); + } +); + +do_action( 'init' ); + +echo "\n[1] Identity store can be provided directly through context:\n"; +agents_api_smoke_assert_equals( true, wp_get_agent_identity_store( array( 'identity_store' => $store ) ) === $store, 'context identity store is resolved', $failures, $passes ); + +echo "\n[2] A registered agent materializes through the identity store:\n"; +$identity = wp_materialize_agent_identity( 'kitchen-agent', $store, array( 'instance_key' => 'Site:42' ) ); +agents_api_smoke_assert_equals( true, $identity instanceof AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity, 'single registered agent materializes', $failures, $passes ); +agents_api_smoke_assert_equals( 'kitchen-agent', $identity instanceof AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity ? $identity->scope->normalize()->agent_slug : null, 'identity scope uses registered slug', $failures, $passes ); +agents_api_smoke_assert_equals( 7, $identity instanceof AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity ? $identity->scope->normalize()->owner_user_id : null, 'owner resolver supplies owner user ID', $failures, $passes ); +agents_api_smoke_assert_equals( 'site:42', $identity instanceof AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity ? $identity->scope->normalize()->instance_key : null, 'caller instance key is normalized', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'model' => 'gpt-test' ), $identity instanceof AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity ? $identity->config : null, 'agent default config is passed to first materialization', $failures, $passes ); +agents_api_smoke_assert_equals( 'example/example.php', $identity instanceof AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity ? ( $identity->meta['source_plugin'] ?? null ) : null, 'agent source metadata is passed to first materialization', $failures, $passes ); + +echo "\n[3] Registered agent materialization is idempotent for the same scope:\n"; +$second_identity = wp_materialize_agent_identity( 'kitchen-agent', $store, array( 'instance_key' => 'Site:42' ) ); +agents_api_smoke_assert_equals( $identity instanceof AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity ? $identity->id : null, $second_identity instanceof AgentsAPI\Core\Identity\WP_Agent_Materialized_Identity ? $second_identity->id : null, 'same identity store row is reused', $failures, $passes ); + +echo "\n[4] Missing stores and unknown agents no-op cleanly:\n"; +agents_api_smoke_assert_equals( array(), wp_materialize_registered_agent_identities(), 'no store means no materialization', $failures, $passes ); +agents_api_smoke_assert_equals( null, wp_materialize_agent_identity( 'missing-agent', $store ), 'unknown agent returns null', $failures, $passes ); + +echo "\n[5] All registered agents can materialize through a filter-provided store:\n"; +add_filter( + 'wp_agent_identity_store', + static function () use ( $store ): AgentsAPI\Core\Identity\WP_Agent_Identity_Store { + return $store; + } +); + +$identities = wp_materialize_registered_agent_identities( + null, + array( + 'owner_user_id' => 13, + 'instance_key' => 'Site:42', + 'meta' => array( 'materialized_by' => 'smoke' ), + ) +); +agents_api_smoke_assert_equals( array( 'kitchen-agent', 'pantry-agent' ), array_keys( $identities ), 'all registered agents materialize via filter-provided store', $failures, $passes ); +agents_api_smoke_assert_equals( 13, $identities['pantry-agent']->scope->normalize()->owner_user_id ?? null, 'explicit owner_user_id overrides defaults', $failures, $passes ); +agents_api_smoke_assert_equals( 'smoke', $identities['pantry-agent']->meta['materialized_by'] ?? null, 'caller metadata is merged into materialization metadata', $failures, $passes ); +agents_api_smoke_assert_equals( false, class_exists( 'DataMachine_Agent_Store', false ), 'materialization lifecycle does not load Data Machine classes', $failures, $passes ); + +agents_api_smoke_finish( 'Agents API identity store materialization', $failures, $passes );