diff --git a/agents-api.php b/agents-api.php index 6d74ef3..7ed4196 100644 --- a/agents-api.php +++ b/agents-api.php @@ -76,6 +76,8 @@ require_once AGENTS_API_PATH . 'src/Auth/register-agent-access-abilities.php'; require_once AGENTS_API_PATH . 'src/Runtime/functions-input-normalization.php'; require_once AGENTS_API_PATH . 'src/Runtime/functions-sse-response.php'; +require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-runtime-profile.php'; +require_once AGENTS_API_PATH . 'src/Runtime/interface-wp-agent-runtime-profile-provider.php'; require_once AGENTS_API_PATH . 'src/Context/class-wp-agent-context-injection-policy.php'; require_once AGENTS_API_PATH . 'src/Context/class-wp-agent-memory-layer.php'; require_once AGENTS_API_PATH . 'src/Context/class-wp-agent-memory-registry.php'; @@ -140,6 +142,8 @@ require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-default-tool-tier-resolver.php'; require_once AGENTS_API_PATH . 'src/Tools/register-agent-ability-meta-abilities.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-conversation-request.php'; +require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-runtime-profile-resolver.php'; +require_once AGENTS_API_PATH . 'src/Runtime/functions-agent-runtime-profile.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-conversation-runner.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-provider-turn-request.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-provider-turn-result.php'; diff --git a/composer.json b/composer.json index 475a8f4..9d16a9d 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ "php tests/package-adoption-orchestration-smoke.php", "php tests/execution-principal-smoke.php", "php tests/effective-agent-resolver-smoke.php", + "php tests/runtime-profile-smoke.php", "php tests/caller-context-smoke.php", "php tests/authorization-smoke.php", "php tests/autonomous-capability-ceiling-smoke.php", diff --git a/src/Runtime/class-wp-agent-runtime-profile-resolver.php b/src/Runtime/class-wp-agent-runtime-profile-resolver.php new file mode 100644 index 0000000..5caf008 --- /dev/null +++ b/src/Runtime/class-wp-agent-runtime-profile-resolver.php @@ -0,0 +1,330 @@ + runtime_overrides > host + * providers > config arrays. Provider and model are resolved independently so + * a higher-priority source can provide one field while another source provides + * the other. Empty strings are valid resolved field values. + * + * @param \WP_Agent $agent Registered agent definition. + * @param array $context Runtime resolution context. + * @return WP_Agent_Runtime_Profile|null Resolved profile, or null when no binding exists. + */ + public function resolve( \WP_Agent $agent, array $context = array() ): ?WP_Agent_Runtime_Profile { + $provider_id = null; + $model_id = null; + $provenance = array( + 'config_sources' => array(), + ); + $identity = $this->identity_payload( $context['identity'] ?? null ); + + $this->apply_context_overrides( $context, $provider_id, $model_id, $provenance ); + $this->apply_runtime_overrides( $agent, $context, $provider_id, $model_id, $provenance ); + $this->apply_host_providers( $agent, $context, $provider_id, $model_id, $identity, $provenance ); + $this->apply_config_sources( $agent, $context, $provider_id, $model_id, $provenance ); + + if ( null === $provider_id && null === $model_id && null === $identity ) { + return $this->apply_final_filter( null, $agent, $context ); + } + + $profile = new WP_Agent_Runtime_Profile( + $agent->get_slug(), + (string) ( $provider_id ?? '' ), + (string) ( $model_id ?? '' ), + $identity, + $provenance + ); + + return $this->apply_final_filter( $profile, $agent, $context ); + } + + /** + * @param array $context Runtime context. + * @param string|null $provider_id Resolved provider id. + * @param string|null $model_id Resolved model id. + * @param array $provenance Field provenance. + */ + private function apply_context_overrides( array $context, ?string &$provider_id, ?string &$model_id, array &$provenance ): void { + $model = is_array( $context['model'] ?? null ) ? $context['model'] : array(); + + if ( null === $provider_id && array_key_exists( 'provider_id', $context ) && is_scalar( $context['provider_id'] ) ) { + $provider_id = (string) $context['provider_id']; + $provenance['provider_id'] = array( + 'source' => 'context', + 'path' => 'provider_id', + ); + } elseif ( null === $provider_id && array_key_exists( 'provider_id', $model ) && is_scalar( $model['provider_id'] ) ) { + $provider_id = (string) $model['provider_id']; + $provenance['provider_id'] = array( + 'source' => 'context', + 'path' => 'model.provider_id', + ); + } + + if ( null === $model_id && array_key_exists( 'model_id', $context ) && is_scalar( $context['model_id'] ) ) { + $model_id = (string) $context['model_id']; + $provenance['model_id'] = array( + 'source' => 'context', + 'path' => 'model_id', + ); + } elseif ( null === $model_id && array_key_exists( 'model_id', $model ) && is_scalar( $model['model_id'] ) ) { + $model_id = (string) $model['model_id']; + $provenance['model_id'] = array( + 'source' => 'context', + 'path' => 'model.model_id', + ); + } + } + + /** + * @param \WP_Agent $agent Agent definition. + * @param array $context Runtime context. + * @param string|null $provider_id Resolved provider id. + * @param string|null $model_id Resolved model id. + * @param array $provenance Field provenance. + */ + private function apply_runtime_overrides( \WP_Agent $agent, array $context, ?string &$provider_id, ?string &$model_id, array &$provenance ): void { + $overrides = $context['runtime_overrides'] ?? null; + if ( ! $overrides instanceof \WP_Agent_Runtime_Overrides ) { + $overrides = $agent->runtime_overrides(); + } + + if ( null === $provider_id && null !== $overrides->provider_id() ) { + $provider_id = $overrides->provider_id(); + $provenance['provider_id'] = array( + 'source' => 'runtime_overrides', + 'path' => 'provider_id', + ); + } + + if ( null === $model_id && null !== $overrides->model_id() ) { + $model_id = $overrides->model_id(); + $provenance['model_id'] = array( + 'source' => 'runtime_overrides', + 'path' => 'model_id', + ); + } + } + + /** + * @param \WP_Agent $agent Agent definition. + * @param array $context Runtime context. + * @param string|null $provider_id Resolved provider id. + * @param string|null $model_id Resolved model id. + * @param array|null $identity Identity payload. + * @param array $provenance Field provenance. + */ + private function apply_host_providers( \WP_Agent $agent, array $context, ?string &$provider_id, ?string &$model_id, ?array &$identity, array &$provenance ): void { + foreach ( $this->get_profile_providers( $agent, $context ) as $index => $provider ) { + $profile = $provider->resolve_agent_runtime_profile( $agent, $context ); + if ( ! $profile instanceof WP_Agent_Runtime_Profile ) { + continue; + } + + $host_provenance = $profile->provenance(); + if ( null === $provider_id ) { + $provider_id = $profile->provider_id(); + $provenance['provider_id'] = is_array( $host_provenance['provider_id'] ?? null ) ? $host_provenance['provider_id'] : array( + 'source' => 'runtime_profile_provider', + 'path' => 'runtime_profile_providers.' . $index . '.provider_id', + ); + } + + if ( null === $model_id ) { + $model_id = $profile->model_id(); + $provenance['model_id'] = is_array( $host_provenance['model_id'] ?? null ) ? $host_provenance['model_id'] : array( + 'source' => 'runtime_profile_provider', + 'path' => 'runtime_profile_providers.' . $index . '.model_id', + ); + } + + if ( null === $identity && null !== $profile->identity() ) { + $identity = $profile->identity(); + } + + $this->append_config_source( $provenance, 'runtime_profile_provider', 'runtime_profile_providers.' . $index ); + break; + } + } + + /** + * @param \WP_Agent $agent Agent definition. + * @param array $context Runtime context. + * @param string|null $provider_id Resolved provider id. + * @param string|null $model_id Resolved model id. + * @param array $provenance Field provenance. + */ + private function apply_config_sources( \WP_Agent $agent, array $context, ?string &$provider_id, ?string &$model_id, array &$provenance ): void { + $mode = isset( $context['mode'] ) && is_scalar( $context['mode'] ) ? trim( (string) $context['mode'] ) : ''; + $sources = array(); + + if ( is_array( $context['agent_config'] ?? null ) ) { + $sources[] = array( 'source' => 'context.agent_config', 'config' => $this->string_keyed_array( $context['agent_config'] ) ); + } + + $identity = $context['identity'] ?? null; + if ( $identity instanceof WP_Agent_Materialized_Identity ) { + $sources[] = array( 'source' => 'identity.config', 'config' => $this->string_keyed_array( $identity->config ) ); + } + + $sources[] = array( 'source' => 'agent.default_config', 'config' => $this->string_keyed_array( $agent->get_default_config() ) ); + + foreach ( $sources as $source ) { + $config = $source['config']; + if ( array() === $config ) { + continue; + } + + $this->append_config_source( $provenance, $source['source'], '' ); + + $mode_models = is_array( $config['mode_models'] ?? null ) ? $config['mode_models'] : array(); + if ( '' !== $mode && is_array( $mode_models[ $mode ] ?? null ) ) { + $mode_config = $this->string_keyed_array( $mode_models[ $mode ] ); + $this->apply_config_field( $mode_config, 'provider_id', 'provider', 'mode_models.' . $mode, (string) $source['source'], $provider_id, $provenance ); + $this->apply_config_field( $mode_config, 'model_id', 'model', 'mode_models.' . $mode, (string) $source['source'], $model_id, $provenance ); + } + + $this->apply_config_field( $config, 'default_provider_id', 'default_provider', '', (string) $source['source'], $provider_id, $provenance ); + $this->apply_config_field( $config, 'default_model_id', 'default_model', '', (string) $source['source'], $model_id, $provenance ); + + if ( null !== $provider_id && null !== $model_id ) { + break; + } + } + } + + /** + * @param array $config Config map. + * @param string $primary_key Preferred key. + * @param string $fallback_key Fallback key. + * @param string $prefix Provenance path prefix. + * @param string $source Provenance source. + * @param string|null $target Resolved target field. + * @param array $provenance Field provenance. + */ + private function apply_config_field( array $config, string $primary_key, string $fallback_key, string $prefix, string $source, ?string &$target, array &$provenance ): void { + if ( null !== $target ) { + return; + } + + $key = null; + $value = null; + if ( array_key_exists( $primary_key, $config ) && is_scalar( $config[ $primary_key ] ) ) { + $key = $primary_key; + $value = $config[ $primary_key ]; + } elseif ( array_key_exists( $fallback_key, $config ) && is_scalar( $config[ $fallback_key ] ) ) { + $key = $fallback_key; + $value = $config[ $fallback_key ]; + } + + if ( null === $key ) { + return; + } + + $field = str_contains( $primary_key, 'provider' ) ? 'provider_id' : 'model_id'; + $target = (string) $value; + + $provenance[ $field ] = array( + 'source' => $source, + 'path' => '' === $prefix ? $key : $prefix . '.' . $key, + ); + } + + /** + * @param \WP_Agent $agent Agent definition. + * @param array $context Runtime context. + * @return WP_Agent_Runtime_Profile_Provider[] Providers. + */ + private function get_profile_providers( \WP_Agent $agent, array $context ): array { + $providers = array(); + if ( is_array( $context['runtime_profile_providers'] ?? null ) ) { + $providers = array_merge( $providers, $context['runtime_profile_providers'] ); + } + + if ( function_exists( 'apply_filters' ) ) { + $providers = apply_filters( 'agents_api_runtime_profile_providers', $providers, $agent, $context ); + } + + return array_values( + array_filter( + is_array( $providers ) ? $providers : array(), + static fn( $provider ): bool => $provider instanceof WP_Agent_Runtime_Profile_Provider + ) + ); + } + + /** + * @param mixed $identity Raw identity context. + * @return array|null Identity payload. + */ + private function identity_payload( $identity ): ?array { + if ( $identity instanceof WP_Agent_Materialized_Identity ) { + return $identity->to_array(); + } + + return is_array( $identity ) ? $this->string_keyed_array( $identity ) : null; + } + + /** + * @param array $provenance Field provenance. + * @param string $source Source label. + * @param string $path Source path. + */ + private function append_config_source( array &$provenance, string $source, string $path ): void { + if ( ! is_array( $provenance['config_sources'] ?? null ) ) { + $provenance['config_sources'] = array(); + } + + $provenance['config_sources'][] = array( + 'source' => $source, + 'path' => $path, + ); + } + + /** + * @param array $value Raw map. + * @return array + */ + private function string_keyed_array( array $value ): array { + $result = array(); + foreach ( $value as $key => $item ) { + if ( is_string( $key ) ) { + $result[ $key ] = $item; + } + } + + return $result; + } + + /** + * @param WP_Agent_Runtime_Profile|null $profile Profile before final filter. + * @param \WP_Agent $agent Agent definition. + * @param array $context Runtime context. + * @return WP_Agent_Runtime_Profile|null Filtered profile. + */ + private function apply_final_filter( ?WP_Agent_Runtime_Profile $profile, \WP_Agent $agent, array $context ): ?WP_Agent_Runtime_Profile { + if ( function_exists( 'apply_filters' ) ) { + $profile = apply_filters( 'agents_api_resolved_agent_runtime_profile', $profile, $agent, $context ); + } + + return $profile instanceof WP_Agent_Runtime_Profile ? $profile : null; + } +} diff --git a/src/Runtime/class-wp-agent-runtime-profile.php b/src/Runtime/class-wp-agent-runtime-profile.php new file mode 100644 index 0000000..59aa01c --- /dev/null +++ b/src/Runtime/class-wp-agent-runtime-profile.php @@ -0,0 +1,68 @@ +|null $identity Optional materialized identity payload. + * @param array $provenance Field-level provenance metadata. + */ + public function __construct( + private readonly string $agent_slug, + private readonly string $provider_id, + private readonly string $model_id, + private readonly ?array $identity = null, + private readonly array $provenance = array() + ) {} + + /** @return string Agent slug. */ + public function agent_slug(): string { + return $this->agent_slug; + } + + /** @return string Provider identifier. */ + public function provider_id(): string { + return $this->provider_id; + } + + /** @return string Model identifier. */ + public function model_id(): string { + return $this->model_id; + } + + /** @return array|null Optional materialized identity payload. */ + public function identity(): ?array { + return $this->identity; + } + + /** @return array Field-level provenance metadata. */ + public function provenance(): array { + return $this->provenance; + } + + /** + * Return a stable array shape for diagnostics and handoff to callers. + * + * @return array + */ + public function to_array(): array { + return array( + 'agent_slug' => $this->agent_slug, + 'provider_id' => $this->provider_id, + 'model_id' => $this->model_id, + 'identity' => $this->identity, + 'provenance' => $this->provenance, + ); + } +} diff --git a/src/Runtime/functions-agent-runtime-profile.php b/src/Runtime/functions-agent-runtime-profile.php new file mode 100644 index 0000000..7d61d0b --- /dev/null +++ b/src/Runtime/functions-agent-runtime-profile.php @@ -0,0 +1,30 @@ + $context Runtime resolution context. + * @return \AgentsAPI\AI\WP_Agent_Runtime_Profile|null Runtime profile, or null when no binding exists. + */ + function wp_resolve_agent_runtime_profile( $agent, array $context = array() ): ?\AgentsAPI\AI\WP_Agent_Runtime_Profile { + if ( is_string( $agent ) ) { + $agent = function_exists( 'wp_get_agent' ) ? wp_get_agent( $agent ) : null; + } + + if ( ! $agent instanceof WP_Agent ) { + return null; + } + + $resolver = new \AgentsAPI\AI\WP_Agent_Runtime_Profile_Resolver(); + return $resolver->resolve( $agent, $context ); + } +} diff --git a/src/Runtime/interface-wp-agent-runtime-profile-provider.php b/src/Runtime/interface-wp-agent-runtime-profile-provider.php new file mode 100644 index 0000000..a02fff3 --- /dev/null +++ b/src/Runtime/interface-wp-agent-runtime-profile-provider.php @@ -0,0 +1,25 @@ + $context Runtime resolution context. + * @return WP_Agent_Runtime_Profile|null Runtime profile, or null when this provider has no binding. + */ + public function resolve_agent_runtime_profile( \WP_Agent $agent, array $context ): ?WP_Agent_Runtime_Profile; +} diff --git a/tests/runtime-profile-smoke.php b/tests/runtime-profile-smoke.php new file mode 100644 index 0000000..3097d79 --- /dev/null +++ b/tests/runtime-profile-smoke.php @@ -0,0 +1,114 @@ +get_slug(), + 'host-provider', + 'host-model', + null, + array( + 'provider_id' => array( 'source' => 'host', 'path' => 'provider_id' ), + 'model_id' => array( 'source' => 'host', 'path' => 'model_id' ), + 'config_sources' => array( array( 'source' => 'host', 'path' => 'profile' ) ), + ) + ); + } +} + +add_action( + 'wp_agents_api_init', + static function () { + wp_register_agent( + 'runtime-profile-agent', + array( + 'default_config' => array( + 'mode_models' => array( + 'design' => array( + 'provider_id' => 'mode-provider', + 'model_id' => 'mode-model', + ), + ), + 'default_provider' => 'fallback-provider', + 'default_model' => 'fallback-model', + ), + ) + ); + } +); + +do_action( 'init' ); + +$agent = wp_get_agent( 'runtime-profile-agent' ); +agents_api_smoke_assert_equals( true, $agent instanceof WP_Agent, 'agent registered for runtime profile smoke', $failures, $passes ); + +$explicit = wp_resolve_agent_runtime_profile( + $agent, + array( + 'provider_id' => 'context-provider', + 'model' => array( 'model_id' => 'context-model' ), + 'mode' => 'design', + ) +); +agents_api_smoke_assert_equals( 'context-provider', $explicit instanceof WP_Agent_Runtime_Profile ? $explicit->provider_id() : null, 'explicit provider override wins', $failures, $passes ); +agents_api_smoke_assert_equals( 'context-model', $explicit instanceof WP_Agent_Runtime_Profile ? $explicit->model_id() : null, 'explicit nested model override wins', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'source' => 'context', 'path' => 'provider_id' ), $explicit instanceof WP_Agent_Runtime_Profile ? $explicit->provenance()['provider_id'] : null, 'explicit provider provenance records context override', $failures, $passes ); + +$mode_profile = wp_resolve_agent_runtime_profile( 'runtime-profile-agent', array( 'mode' => 'design' ) ); +agents_api_smoke_assert_equals( 'mode-provider', $mode_profile instanceof WP_Agent_Runtime_Profile ? $mode_profile->provider_id() : null, 'mode provider resolves from default config', $failures, $passes ); +agents_api_smoke_assert_equals( 'mode-model', $mode_profile instanceof WP_Agent_Runtime_Profile ? $mode_profile->model_id() : null, 'mode model resolves from default config', $failures, $passes ); +agents_api_smoke_assert_equals( 'mode_models.design.provider_id', $mode_profile instanceof WP_Agent_Runtime_Profile ? $mode_profile->provenance()['provider_id']['path'] : null, 'mode provider provenance path is stable', $failures, $passes ); +agents_api_smoke_assert_equals( 'mode_models.design.model_id', $mode_profile instanceof WP_Agent_Runtime_Profile ? $mode_profile->provenance()['model_id']['path'] : null, 'mode model provenance path is stable', $failures, $passes ); + +$default_profile = wp_resolve_agent_runtime_profile( $agent, array( 'mode' => 'unknown' ) ); +agents_api_smoke_assert_equals( 'fallback-provider', $default_profile instanceof WP_Agent_Runtime_Profile ? $default_profile->provider_id() : null, 'default provider resolves without mode match', $failures, $passes ); +agents_api_smoke_assert_equals( 'fallback-model', $default_profile instanceof WP_Agent_Runtime_Profile ? $default_profile->model_id() : null, 'default model resolves without mode match', $failures, $passes ); + +$host_profile = wp_resolve_agent_runtime_profile( + $agent, + array( + 'runtime_profile_providers' => array( new Agents_API_Smoke_Runtime_Profile_Provider() ), + ) +); +agents_api_smoke_assert_equals( 'host-provider', $host_profile instanceof WP_Agent_Runtime_Profile ? $host_profile->provider_id() : null, 'host profile provider is honored before config', $failures, $passes ); +agents_api_smoke_assert_equals( 'host-model', $host_profile instanceof WP_Agent_Runtime_Profile ? $host_profile->model_id() : null, 'host profile model is honored before config', $failures, $passes ); + +agents_api_smoke_assert_equals( + array( + 'agent_slug' => 'runtime-profile-agent', + 'provider_id' => 'host-provider', + 'model_id' => 'host-model', + 'identity' => null, + 'provenance' => $host_profile instanceof WP_Agent_Runtime_Profile ? $host_profile->provenance() : array(), + ), + $host_profile instanceof WP_Agent_Runtime_Profile ? $host_profile->to_array() : null, + 'runtime profile to_array shape is stable', + $failures, + $passes +); + +agents_api_smoke_finish( 'Agents API runtime profile', $failures, $passes );