diff --git a/README.md b/README.md index 2c13de4..21c8aff 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Agents API sits between tool/action discovery and product-specific automation. I - Generic multi-turn conversation loop sequencing around caller-owned adapters. - Iteration budget primitives for bounded execution across configurable dimensions. - Tool-call mediation contracts and runtime tool declaration value objects. +- Generic tool visibility policy and action policy resolver contracts. - Conversation transcript store contracts. - Consent policy contracts for memory, transcripts, sharing, and escalation. - Tool source registration, parameter normalization, tool-call mediation, and execution result contracts. @@ -143,6 +144,11 @@ wp_register_agent( - `AgentsAPI\AI\Context\ContextConflictResolution` - `AgentsAPI\AI\Context\ContextConflictResolverInterface` - `AgentsAPI\AI\Context\DefaultContextConflictResolver` +- `WP_Agent_Tool_Policy` +- `WP_Agent_Tool_Policy_Filter` +- `WP_Agent_Tool_Access_Policy_Interface` +- `WP_Agent_Action_Policy_Resolver` +- `WP_Agent_Action_Policy_Provider_Interface` - `AgentsAPI\Core\Workspace\AgentWorkspaceScope` - `AgentsAPI\Core\Database\Chat\ConversationTranscriptStoreInterface` - `AgentsAPI\Core\FilesRepository\AgentMemoryStoreInterface` and memory value objects, including provenance/trust metadata contracts @@ -223,6 +229,67 @@ final class RepoMemoryValidator implements AgentsAPI\Core\FilesRepository\AgentM Agents API defines the contracts only. Concrete stores decide how metadata is physically materialized, how ranking is executed, and which workspace facts are supplied to validators. +## Tool Visibility And Action Policy + +Agents API owns generic policy contracts for deciding which tools are visible to an agent and how a called tool is allowed to execute. Consumers still own concrete tool sources, concrete execution adapters, approval storage, UI, workflows, and any product-specific mandatory tools. + +Tool visibility is resolved by `WP_Agent_Tool_Policy` over an already-gathered tool map. The resolver applies generic layers only: + +- Tool-declared runtime modes through `mode` or `modes`. +- Caller-owned access checks through `tool_access_checker`. +- Registered agent or runtime `tool_policy` config with `allow` / `deny`, `tools`, and `categories`. +- Host-provided `WP_Agent_Tool_Access_Policy_Interface` policy fragments. +- Runtime `categories`, `allow_only`, and explicit `deny` lists. + +Mandatory tools are not hardcoded by Agents API. A consumer that needs mandatory runtime plumbing can return `mandatory_tools` or `mandatory_categories` from a policy provider, and explicit deny still wins. + +```php +$visible_tools = ( new WP_Agent_Tool_Policy() )->resolve( + $all_tools, + array( + 'mode' => 'chat', + 'agent_config' => array( + 'tool_policy' => array( + 'mode' => 'allow', + 'categories' => array( 'read' ), + ), + ), + 'tool_policy_providers' => array( $consumer_policy_provider ), + ) +); +``` + +Action policy is resolved by `WP_Agent_Action_Policy_Resolver` and always returns one of the canonical values from `AgentsAPI\AI\Tools\ActionPolicy`: `direct`, `preview`, or `forbidden`. + +Resolution order is: + +1. Explicit runtime `deny` list resolves a tool to `forbidden`. +2. Registered agent or runtime `action_policy.tools[tool_name]`. +3. Registered agent or runtime `action_policy.categories[category]`. +4. Host-provided `WP_Agent_Action_Policy_Provider_Interface` providers. +5. Tool-declared `action_policy` default. +6. Tool-declared mode-specific `action_policy_` default. +7. Global default `direct`. +8. Final `agents_api_tool_action_policy` filter, if present. + +```php +$policy = ( new WP_Agent_Action_Policy_Resolver() )->resolve_for_tool( + array( + 'tool_name' => 'example/publish', + 'tool_def' => $visible_tools['example/publish'], + 'mode' => 'chat', + 'agent_config' => array( + 'action_policy' => array( + 'categories' => array( + 'publishing' => 'preview', + ), + ), + ), + 'action_policy_providers' => array( $consumer_action_policy_provider ), + ) +); +``` + ## Workspace Scope `AgentsAPI\Core\Workspace\AgentWorkspaceScope` is the generic workspace identity shared by memory, transcript, persistence, and audit adapters. It is deliberately broader than a WordPress site ID: diff --git a/agents-api.php b/agents-api.php index 3bd0db1..612193b 100644 --- a/agents-api.php +++ b/agents-api.php @@ -67,6 +67,12 @@ require_once AGENTS_API_PATH . 'src/Runtime/AgentExecutionPrincipal.php'; require_once AGENTS_API_PATH . 'src/Runtime/AgentCompactionItem.php'; require_once AGENTS_API_PATH . 'src/Tools/RuntimeToolDeclaration.php'; +require_once AGENTS_API_PATH . 'src/Tools/ActionPolicy.php'; +require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-tool-access-policy-interface.php'; +require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-action-policy-provider-interface.php'; +require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-tool-policy-filter.php'; +require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-tool-policy.php'; +require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-action-policy-resolver.php'; require_once AGENTS_API_PATH . 'src/Runtime/AgentConversationRequest.php'; require_once AGENTS_API_PATH . 'src/Runtime/AgentConversationRunnerInterface.php'; require_once AGENTS_API_PATH . 'src/Runtime/AgentConversationCompletionDecision.php'; @@ -79,7 +85,6 @@ require_once AGENTS_API_PATH . 'src/Runtime/AgentConversationResult.php'; require_once AGENTS_API_PATH . 'src/Runtime/AgentConversationLoop.php'; require_once AGENTS_API_PATH . 'src/Tools/ToolCall.php'; -require_once AGENTS_API_PATH . 'src/Tools/ActionPolicy.php'; require_once AGENTS_API_PATH . 'src/Tools/ToolParameters.php'; require_once AGENTS_API_PATH . 'src/Tools/ToolExecutionResult.php'; require_once AGENTS_API_PATH . 'src/Tools/ToolExecutorInterface.php'; diff --git a/composer.json b/composer.json index 7b6bec8..269dee7 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,8 @@ "php tests/authorization-smoke.php", "php tests/action-policy-values-smoke.php", "php tests/consent-policy-smoke.php", + "php tests/tool-policy-contracts-smoke.php", + "php tests/action-policy-resolver-smoke.php", "php tests/tool-runtime-smoke.php", "php tests/pending-action-store-contract-smoke.php", "php tests/approval-resolver-contract-smoke.php", diff --git a/src/Tools/class-wp-agent-action-policy-provider-interface.php b/src/Tools/class-wp-agent-action-policy-provider-interface.php new file mode 100644 index 0000000..1a45e71 --- /dev/null +++ b/src/Tools/class-wp-agent-action-policy-provider-interface.php @@ -0,0 +1,26 @@ + $context Action policy context. + * @return string|null Action policy value, or null for no opinion. + */ + public function get_action_policy( array $context ): ?string; + } +} diff --git a/src/Tools/class-wp-agent-action-policy-resolver.php b/src/Tools/class-wp-agent-action-policy-resolver.php new file mode 100644 index 0000000..22be5e5 --- /dev/null +++ b/src/Tools/class-wp-agent-action-policy-resolver.php @@ -0,0 +1,235 @@ +policy_providers = is_array( $policy_providers ) ? $policy_providers : array(); + $this->tool_filter = $tool_filter ?? new WP_Agent_Tool_Policy_Filter(); + } + + /** + * Resolve action policy for one tool invocation. + * + * @param array $context Resolution context. + * @return string One of direct, preview, forbidden. + */ + public function resolve_for_tool( array $context ): string { + $tool_name = (string) ( $context['tool_name'] ?? '' ); + $mode = (string) ( $context['mode'] ?? WP_Agent_Tool_Policy::RUNTIME_CHAT ); + $tool_def = is_array( $context['tool_def'] ?? null ) ? $context['tool_def'] : array(); + + if ( '' === $tool_name ) { + return ActionPolicy::DIRECT; + } + + if ( in_array( $tool_name, $this->string_list( $context['deny'] ?? array() ), true ) ) { + return $this->apply_filter( ActionPolicy::FORBIDDEN, $tool_name, $mode, $context ); + } + + $agent_policy = $this->agent_action_policy_from_context( $context ); + $agent_tool = $this->agent_tool_override( $agent_policy, $tool_name ); + if ( null !== $agent_tool ) { + return $this->apply_filter( $agent_tool, $tool_name, $mode, $context ); + } + + $agent_category = $this->agent_category_override( $agent_policy, $tool_def ); + if ( null !== $agent_category ) { + return $this->apply_filter( $agent_category, $tool_name, $mode, $context ); + } + + foreach ( $this->get_policy_providers( $context ) as $provider ) { + $provided = ActionPolicy::normalize( $provider->get_action_policy( $context ) ); + if ( null !== $provided ) { + return $this->apply_filter( $provided, $tool_name, $mode, $context ); + } + } + + $tool_default = $this->tool_declared_default( $tool_def ); + if ( null !== $tool_default ) { + return $this->apply_filter( $tool_default, $tool_name, $mode, $context ); + } + + $mode_default = $this->mode_declared_default( $tool_def, $mode ); + if ( null !== $mode_default ) { + return $this->apply_filter( $mode_default, $tool_name, $mode, $context ); + } + + return $this->apply_filter( ActionPolicy::DIRECT, $tool_name, $mode, $context ); + } + + /** + * Return policy providers from constructor, context, and filters. + * + * @param array $context Runtime context. + * @return WP_Agent_Action_Policy_Provider_Interface[] Providers. + */ + private function get_policy_providers( array $context ): array { + $providers = $this->policy_providers; + if ( is_array( $context['action_policy_providers'] ?? null ) ) { + $providers = array_merge( $providers, $context['action_policy_providers'] ); + } + + if ( function_exists( 'apply_filters' ) ) { + $providers = apply_filters( 'agents_api_action_policy_providers', $providers, $context, $this ); + } + + return array_values( + array_filter( + is_array( $providers ) ? $providers : array(), + static fn( $provider ): bool => $provider instanceof WP_Agent_Action_Policy_Provider_Interface + ) + ); + } + + /** + * Return action policy from runtime or registered agent config. + * + * @param array $context Runtime context. + * @return array Policy map. + */ + private function agent_action_policy_from_context( array $context ): array { + if ( is_array( $context['action_policy'] ?? null ) ) { + return $context['action_policy']; + } + + $agent_config = array(); + if ( is_array( $context['agent_config'] ?? null ) ) { + $agent_config = $context['agent_config']; + } elseif ( ( $context['agent'] ?? null ) instanceof WP_Agent ) { + $agent_config = $context['agent']->get_default_config(); + } else { + $agent_slug = (string) ( $context['agent_slug'] ?? ( $context['agent_id'] ?? '' ) ); + if ( '' !== $agent_slug && function_exists( 'wp_get_agent' ) ) { + $agent = wp_get_agent( $agent_slug ); + if ( $agent instanceof WP_Agent ) { + $agent_config = $agent->get_default_config(); + } + } + } + + return is_array( $agent_config['action_policy'] ?? null ) ? $agent_config['action_policy'] : array(); + } + + /** + * Resolve per-tool agent override. + * + * @param array $policy Agent policy. + * @param string $tool_name Tool name. + * @return string|null Normalized policy or null. + */ + private function agent_tool_override( array $policy, string $tool_name ): ?string { + $tools = is_array( $policy['tools'] ?? null ) ? $policy['tools'] : array(); + return ActionPolicy::normalize( $tools[ $tool_name ] ?? null ); + } + + /** + * Resolve per-category agent override. + * + * @param array $policy Agent policy. + * @param array $tool_def Tool definition. + * @return string|null Normalized policy or null. + */ + private function agent_category_override( array $policy, array $tool_def ): ?string { + $categories = is_array( $policy['categories'] ?? null ) ? $policy['categories'] : array(); + foreach ( $categories as $category => $raw_policy ) { + if ( ! is_string( $category ) || ! $this->tool_filter->tool_matches_categories( $tool_def, array( $category ) ) ) { + continue; + } + + $policy_value = ActionPolicy::normalize( $raw_policy ); + if ( null !== $policy_value ) { + return $policy_value; + } + } + + return null; + } + + /** + * Return tool-declared default action policy. + * + * @param array $tool_def Tool definition. + * @return string|null Normalized policy or null. + */ + private function tool_declared_default( array $tool_def ): ?string { + return ActionPolicy::normalize( $tool_def['action_policy'] ?? null ); + } + + /** + * Return mode-specific tool-declared action policy. + * + * @param array $tool_def Tool definition. + * @param string $mode Runtime mode. + * @return string|null Normalized policy or null. + */ + private function mode_declared_default( array $tool_def, string $mode ): ?string { + return ActionPolicy::normalize( $tool_def[ 'action_policy_' . $mode ] ?? null ); + } + + /** + * Apply final WordPress filter and keep only canonical values. + * + * @param string $policy Computed policy. + * @param string $tool_name Tool name. + * @param string $mode Runtime mode. + * @param array $context Resolution context. + * @return string Filtered policy. + */ + private function apply_filter( string $policy, string $tool_name, string $mode, array $context ): string { + if ( ! function_exists( 'apply_filters' ) ) { + return $policy; + } + + $filtered = apply_filters( 'agents_api_tool_action_policy', $policy, $tool_name, $mode, $context, $this ); + return ActionPolicy::normalize( $filtered ) ?? $policy; + } + + /** + * Normalize a list of strings. + * + * @param mixed $values Raw list. + * @return string[] Non-empty strings. + */ + private function string_list( $values ): array { + $values = is_array( $values ) ? $values : array( $values ); + $values = array_filter( + array_map( + static fn( $value ) => is_string( $value ) ? trim( $value ) : '', + $values + ), + static fn( string $value ): bool => '' !== $value + ); + + return array_values( array_unique( $values ) ); + } + } +} diff --git a/src/Tools/class-wp-agent-tool-access-policy-interface.php b/src/Tools/class-wp-agent-tool-access-policy-interface.php new file mode 100644 index 0000000..17109ca --- /dev/null +++ b/src/Tools/class-wp-agent-tool-access-policy-interface.php @@ -0,0 +1,27 @@ + $context Runtime context. + * @return array|null Policy fragment, or null for no opinion. + */ + public function get_tool_policy( array $context ): ?array; + } +} diff --git a/src/Tools/class-wp-agent-tool-policy-filter.php b/src/Tools/class-wp-agent-tool-policy-filter.php new file mode 100644 index 0000000..8b08442 --- /dev/null +++ b/src/Tools/class-wp-agent-tool-policy-filter.php @@ -0,0 +1,265 @@ + $tools Tool definitions keyed by tool name. + * @param string $mode Runtime mode. + * @return array Filtered tools. + */ + public function filter_by_mode( array $tools, string $mode ): array { + if ( '' === $mode ) { + return $tools; + } + + $filtered = array(); + foreach ( $tools as $name => $tool ) { + $modes = $tool['modes'] ?? ( $tool['mode'] ?? null ); + if ( null === $modes ) { + $filtered[ $name ] = $tool; + continue; + } + + $modes = is_array( $modes ) ? $modes : array( $modes ); + if ( in_array( $mode, $modes, true ) ) { + $filtered[ $name ] = $tool; + } + } + + return $filtered; + } + + /** + * Filter tools through a caller-owned access checker. + * + * @param array $tools Tool definitions keyed by tool name. + * @param callable $access_checker Callback receiving ($tool, $name). + * @return array Filtered tools. + */ + public function filter_by_access_checker( array $tools, callable $access_checker ): array { + $filtered = array(); + foreach ( $tools as $name => $tool ) { + if ( $access_checker( $tool, $name ) ) { + $filtered[ $name ] = $tool; + } + } + + return $filtered; + } + + /** + * Apply an allow/deny policy to optional tools. + * + * @param array $tools Tool definitions keyed by tool name. + * @param array|null $policy Policy with mode/tools/categories keys. + * @param callable|null $preserve_tool Optional callback for mandatory tools. + * @return array Filtered tools. + */ + public function apply_named_policy( array $tools, ?array $policy, ?callable $preserve_tool = null ): array { + if ( null === $policy ) { + return $tools; + } + + $mode = (string) ( $policy['mode'] ?? WP_Agent_Tool_Policy::MODE_DENY ); + $tool_names = $this->string_list( $policy['tools'] ?? array() ); + $categories = $this->string_list( $policy['categories'] ?? array() ); + $split = $this->split_preserved_tools( $tools, $preserve_tool ); + + if ( empty( $tool_names ) && empty( $categories ) ) { + return WP_Agent_Tool_Policy::MODE_ALLOW === $mode ? $split['preserved'] : $tools; + } + + $filtered = array(); + foreach ( $split['optional'] as $name => $tool ) { + $matches = in_array( $name, $tool_names, true ) || $this->tool_matches_categories( $tool, $categories ); + + if ( WP_Agent_Tool_Policy::MODE_ALLOW === $mode && $matches ) { + $filtered[ $name ] = $tool; + } elseif ( WP_Agent_Tool_Policy::MODE_DENY === $mode && ! $matches ) { + $filtered[ $name ] = $tool; + } + } + + return $split['preserved'] + $filtered; + } + + /** + * Filter tools by category while preserving mandatory tools. + * + * @param array $tools Tool definitions keyed by tool name. + * @param string[] $categories Allowed categories. + * @param callable|null $preserve_tool Optional callback for mandatory tools. + * @return array Filtered tools. + */ + public function filter_by_categories( array $tools, array $categories, ?callable $preserve_tool = null ): array { + $categories = $this->string_list( $categories ); + if ( empty( $categories ) ) { + return $tools; + } + + $filtered = array(); + foreach ( $tools as $name => $tool ) { + if ( $preserve_tool && $preserve_tool( $tool, $name ) ) { + $filtered[ $name ] = $tool; + continue; + } + + if ( $this->tool_matches_categories( $tool, $categories ) ) { + $filtered[ $name ] = $tool; + } + } + + return $filtered; + } + + /** + * Apply an allow-only list while preserving mandatory tools. + * + * @param array $tools Tool definitions keyed by tool name. + * @param string[] $allow_only Tool names to allow. + * @param callable|null $preserve_tool Optional callback for mandatory tools. + * @return array Filtered tools. + */ + public function filter_by_allow_only( array $tools, array $allow_only, ?callable $preserve_tool = null ): array { + $allow_only = $this->string_list( $allow_only ); + $split = $this->split_preserved_tools( $tools, $preserve_tool ); + + return $split['preserved'] + array_intersect_key( $split['optional'], array_flip( $allow_only ) ); + } + + /** + * Whether a tool matches any category in a policy. + * + * @param array $tool Tool definition. + * @param string[] $categories Category slugs. + * @return bool Whether the tool matches. + */ + public function tool_matches_categories( array $tool, array $categories ): bool { + $categories = $this->string_list( $categories ); + if ( empty( $categories ) ) { + return false; + } + + $tool_categories = $this->tool_categories( $tool ); + return (bool) array_intersect( $tool_categories, $categories ); + } + + /** + * Return normalized category slugs declared by a tool. + * + * @param array $tool Tool definition. + * @return string[] Category slugs. + */ + private function tool_categories( array $tool ): array { + $categories = array(); + + foreach ( array( 'category', 'ability_category' ) as $key ) { + if ( is_string( $tool[ $key ] ?? null ) && '' !== $tool[ $key ] ) { + $categories[] = $tool[ $key ]; + } + } + + foreach ( array( 'categories', 'ability_categories' ) as $key ) { + if ( is_array( $tool[ $key ] ?? null ) ) { + $categories = array_merge( $categories, $this->string_list( $tool[ $key ] ) ); + } + } + + if ( class_exists( 'WP_Abilities_Registry' ) ) { + $registry = WP_Abilities_Registry::get_instance(); + foreach ( $this->ability_slugs( $tool ) as $slug ) { + $ability = $registry->get_registered( $slug ); + if ( $ability ) { + $categories[] = $ability->get_category(); + } + } + } + + return array_values( array_unique( $this->string_list( $categories ) ) ); + } + + /** + * Return linked ability slugs from a tool declaration. + * + * @param array $tool Tool definition. + * @return string[] Ability slugs. + */ + private function ability_slugs( array $tool ): array { + $ability_slugs = array(); + if ( is_string( $tool['ability'] ?? null ) && '' !== $tool['ability'] ) { + $ability_slugs[] = $tool['ability']; + } + + if ( is_array( $tool['abilities'] ?? null ) ) { + $ability_slugs = array_merge( $ability_slugs, $this->string_list( $tool['abilities'] ) ); + } + + return array_values( array_unique( $ability_slugs ) ); + } + + /** + * Split mandatory tools from optional tools. + * + * @param array $tools Tool definitions keyed by tool name. + * @param callable|null $preserve_tool Optional callback. + * @return array{preserved: array, optional: array} Split buckets. + */ + private function split_preserved_tools( array $tools, ?callable $preserve_tool ): array { + if ( null === $preserve_tool ) { + return array( + 'preserved' => array(), + 'optional' => $tools, + ); + } + + $preserved = array(); + $optional = array(); + foreach ( $tools as $name => $tool ) { + if ( $preserve_tool( $tool, $name ) ) { + $preserved[ $name ] = $tool; + } else { + $optional[ $name ] = $tool; + } + } + + return array( + 'preserved' => $preserved, + 'optional' => $optional, + ); + } + + /** + * Normalize a list of string values. + * + * @param mixed $values Raw list. + * @return string[] Non-empty strings. + */ + private function string_list( $values ): array { + $values = is_array( $values ) ? $values : array( $values ); + $values = array_filter( + array_map( + static fn( $value ) => is_string( $value ) ? trim( $value ) : '', + $values + ), + static fn( string $value ): bool => '' !== $value + ); + + return array_values( array_unique( $values ) ); + } + } +} diff --git a/src/Tools/class-wp-agent-tool-policy.php b/src/Tools/class-wp-agent-tool-policy.php new file mode 100644 index 0000000..dfd22fb --- /dev/null +++ b/src/Tools/class-wp-agent-tool-policy.php @@ -0,0 +1,235 @@ +policy_providers = is_array( $policy_providers ) ? $policy_providers : array(); + $this->filter = $filter ?? new WP_Agent_Tool_Policy_Filter(); + } + + /** + * Resolve the visible tool set for a runtime context. + * + * @param array $tools Tool definitions keyed by tool name. + * @param array $context Runtime context. + * @return array Visible tools keyed by tool name. + */ + public function resolve( array $tools, array $context = array() ): array { + $mode = (string) ( $context['mode'] ?? self::RUNTIME_CHAT ); + $policies = $this->collect_policies( $context ); + $mandatory_tools = $this->collect_policy_list( $policies, 'mandatory_tools' ); + $mandatory_cats = $this->collect_policy_list( $policies, 'mandatory_categories' ); + $preserve_tool = function ( array $tool, string $name ) use ( $mandatory_tools, $mandatory_cats ): bool { + return in_array( $name, $mandatory_tools, true ) + || true === ( $tool['mandatory'] ?? false ) + || $this->filter->tool_matches_categories( $tool, $mandatory_cats ); + }; + + $tools = $this->filter->filter_by_mode( $tools, $mode ); + + $access_checker = $context['tool_access_checker'] ?? null; + if ( is_callable( $access_checker ) ) { + $tools = $this->filter->filter_by_access_checker( $tools, $access_checker ); + } + + foreach ( $policies as $policy ) { + $tools = $this->filter->apply_named_policy( $tools, $this->normalize_named_policy( $policy ), $preserve_tool ); + } + + $categories = $this->string_list( $context['categories'] ?? array() ); + if ( ! empty( $categories ) ) { + $tools = $this->filter->filter_by_categories( $tools, $categories, $preserve_tool ); + } + + $allow_only = $this->string_list( $context['allow_only'] ?? array() ); + foreach ( $policies as $policy ) { + $allow_only = array_merge( $allow_only, $this->string_list( $policy['allow_only'] ?? array() ) ); + } + if ( ! empty( $allow_only ) ) { + $tools = $this->filter->filter_by_allow_only( $tools, array_values( array_unique( $allow_only ) ), $preserve_tool ); + } + + $deny = $this->string_list( $context['deny'] ?? array() ); + foreach ( $policies as $policy ) { + $deny = array_merge( $deny, $this->string_list( $policy['deny'] ?? array() ) ); + } + if ( ! empty( $deny ) ) { + $tools = array_diff_key( $tools, array_flip( array_values( array_unique( $deny ) ) ) ); + } + + if ( function_exists( 'apply_filters' ) ) { + $filtered = apply_filters( 'agents_api_resolved_tools', $tools, $mode, $context, $this ); + $tools = is_array( $filtered ) ? $filtered : $tools; + } + + return $tools; + } + + /** + * Collect policy fragments from agent config, runtime context, and providers. + * + * @param array $context Runtime context. + * @return array> Policy fragments. + */ + private function collect_policies( array $context ): array { + $policies = array(); + + $agent_config = $this->agent_config_from_context( $context ); + if ( is_array( $agent_config['tool_policy'] ?? null ) ) { + $policies[] = $agent_config['tool_policy']; + } + + if ( is_array( $context['tool_policy'] ?? null ) ) { + $policies[] = $context['tool_policy']; + } + + foreach ( $this->get_policy_providers( $context ) as $provider ) { + $policy = $provider->get_tool_policy( $context ); + if ( is_array( $policy ) ) { + $policies[] = $policy; + } + } + + return $policies; + } + + /** + * Return policy providers from constructor, context, and filter. + * + * @param array $context Runtime context. + * @return WP_Agent_Tool_Access_Policy_Interface[] Providers. + */ + private function get_policy_providers( array $context ): array { + $providers = $this->policy_providers; + if ( is_array( $context['tool_policy_providers'] ?? null ) ) { + $providers = array_merge( $providers, $context['tool_policy_providers'] ); + } + + if ( function_exists( 'apply_filters' ) ) { + $providers = apply_filters( 'agents_api_tool_policy_providers', $providers, $context, $this ); + } + + return array_values( + array_filter( + is_array( $providers ) ? $providers : array(), + static fn( $provider ): bool => $provider instanceof WP_Agent_Tool_Access_Policy_Interface + ) + ); + } + + /** + * Return registered or runtime agent config from context. + * + * @param array $context Runtime context. + * @return array Agent config. + */ + private function agent_config_from_context( array $context ): array { + if ( is_array( $context['agent_config'] ?? null ) ) { + return $context['agent_config']; + } + + $agent = $context['agent'] ?? null; + if ( $agent instanceof WP_Agent ) { + return $agent->get_default_config(); + } + + $agent_slug = (string) ( $context['agent_slug'] ?? ( $context['agent_id'] ?? '' ) ); + if ( '' !== $agent_slug && function_exists( 'wp_get_agent' ) ) { + $registered = wp_get_agent( $agent_slug ); + if ( $registered instanceof WP_Agent ) { + return $registered->get_default_config(); + } + } + + return array(); + } + + /** + * Normalize allow/deny policy shape. + * + * @param array $policy Raw policy. + * @return array|null Normalized policy. + */ + private function normalize_named_policy( array $policy ): ?array { + $mode = (string) ( $policy['mode'] ?? self::MODE_DENY ); + if ( ! in_array( $mode, array( self::MODE_ALLOW, self::MODE_DENY ), true ) ) { + return null; + } + + return array( + 'mode' => $mode, + 'tools' => $this->string_list( $policy['tools'] ?? array() ), + 'categories' => $this->string_list( $policy['categories'] ?? array() ), + ); + } + + /** + * Collect list values from every policy fragment. + * + * @param array> $policies Policy fragments. + * @param string $key Policy key. + * @return string[] Values. + */ + private function collect_policy_list( array $policies, string $key ): array { + $values = array(); + foreach ( $policies as $policy ) { + $values = array_merge( $values, $this->string_list( $policy[ $key ] ?? array() ) ); + } + + return array_values( array_unique( $values ) ); + } + + /** + * Normalize a list of strings. + * + * @param mixed $values Raw list. + * @return string[] Non-empty strings. + */ + private function string_list( $values ): array { + $values = is_array( $values ) ? $values : array( $values ); + $values = array_filter( + array_map( + static fn( $value ) => is_string( $value ) ? trim( $value ) : '', + $values + ), + static fn( string $value ): bool => '' !== $value + ); + + return array_values( array_unique( $values ) ); + } + } +} diff --git a/tests/action-policy-resolver-smoke.php b/tests/action-policy-resolver-smoke.php new file mode 100644 index 0000000..6410217 --- /dev/null +++ b/tests/action-policy-resolver-smoke.php @@ -0,0 +1,114 @@ +resolve_for_tool( + array( + 'tool_name' => 'client/write', + 'deny' => array( 'client/write' ), + ) + ), + 'explicit deny resolves to forbidden', + $failures, + $passes +); + +agents_api_smoke_assert_equals( + AgentsAPI\AI\Tools\ActionPolicy::PREVIEW, + $resolver->resolve_for_tool( + array( + 'tool_name' => 'client/write', + 'agent_config' => array( + 'action_policy' => array( + 'tools' => array( + 'client/write' => 'preview', + ), + ), + ), + ) + ), + 'agent config tool override resolves to preview', + $failures, + $passes +); + +echo "\n[3] Category policy, host providers, and tool defaults compose generically:\n"; +agents_api_smoke_assert_equals( + AgentsAPI\AI\Tools\ActionPolicy::PREVIEW, + $resolver->resolve_for_tool( + array( + 'tool_name' => 'client/publish', + 'tool_def' => array( 'categories' => array( 'publishing' ) ), + 'agent_config' => array( + 'action_policy' => array( + 'categories' => array( + 'publishing' => 'preview', + ), + ), + ), + ) + ), + 'agent config category override resolves to preview', + $failures, + $passes +); + +agents_api_smoke_assert_equals( + AgentsAPI\AI\Tools\ActionPolicy::FORBIDDEN, + $resolver->resolve_for_tool( + array( + 'tool_name' => 'client/private', + 'action_policy_providers' => array( + new class() implements WP_Agent_Action_Policy_Provider_Interface { + public function get_action_policy( array $context ): ?string { + return 'client/private' === ( $context['tool_name'] ?? '' ) ? 'forbidden' : null; + } + }, + ), + ) + ), + 'host action provider resolves to forbidden', + $failures, + $passes +); + +agents_api_smoke_assert_equals( + AgentsAPI\AI\Tools\ActionPolicy::DIRECT, + $resolver->resolve_for_tool( + array( + 'tool_name' => 'client/read', + 'tool_def' => array( 'action_policy' => 'direct' ), + ) + ), + 'tool-declared default resolves to direct', + $failures, + $passes +); + +agents_api_smoke_finish( 'Action policy resolver', $failures, $passes ); diff --git a/tests/bootstrap-smoke.php b/tests/bootstrap-smoke.php index bca0056..62599b7 100644 --- a/tests/bootstrap-smoke.php +++ b/tests/bootstrap-smoke.php @@ -98,6 +98,11 @@ agents_api_smoke_assert_equals( true, class_exists( 'WP_Agent_Default_Consent_Policy' ), 'WP_Agent_Default_Consent_Policy implementation is available', $failures, $passes ); agents_api_smoke_assert_equals( true, class_exists( 'AgentsAPI\\AI\\Consent\\AgentConsentOperation' ), 'AgentConsentOperation vocabulary is available', $failures, $passes ); agents_api_smoke_assert_equals( true, class_exists( 'AgentsAPI\\AI\\Consent\\AgentConsentDecision' ), 'AgentConsentDecision value object is available', $failures, $passes ); +agents_api_smoke_assert_equals( true, class_exists( 'WP_Agent_Tool_Policy' ), 'WP_Agent_Tool_Policy contract is available', $failures, $passes ); +agents_api_smoke_assert_equals( true, class_exists( 'WP_Agent_Tool_Policy_Filter' ), 'WP_Agent_Tool_Policy_Filter contract is available', $failures, $passes ); +agents_api_smoke_assert_equals( true, interface_exists( 'WP_Agent_Tool_Access_Policy_Interface' ), 'WP_Agent_Tool_Access_Policy_Interface contract is available', $failures, $passes ); +agents_api_smoke_assert_equals( true, class_exists( 'WP_Agent_Action_Policy_Resolver' ), 'WP_Agent_Action_Policy_Resolver contract is available', $failures, $passes ); +agents_api_smoke_assert_equals( true, interface_exists( 'WP_Agent_Action_Policy_Provider_Interface' ), 'WP_Agent_Action_Policy_Provider_Interface contract is available', $failures, $passes ); foreach ( $namespace_map as $legacy_class => $target_class ) { agents_api_smoke_assert_equals( true, class_exists( $target_class ) || interface_exists( $target_class ), $target_class . ' contract is available', $failures, $passes ); agents_api_smoke_assert_equals( false, class_exists( $legacy_class, false ) || interface_exists( $legacy_class, false ), $legacy_class . ' compatibility alias is not loaded', $failures, $passes ); diff --git a/tests/tool-policy-contracts-smoke.php b/tests/tool-policy-contracts-smoke.php new file mode 100644 index 0000000..e8e12b9 --- /dev/null +++ b/tests/tool-policy-contracts-smoke.php @@ -0,0 +1,113 @@ + array( + 'name' => 'client/read', + 'categories' => array( 'read' ), + 'modes' => array( 'chat', 'system' ), + ), + 'client/write' => array( + 'name' => 'client/write', + 'categories' => array( 'write' ), + 'modes' => array( 'chat' ), + ), + 'client/mandatory' => array( + 'name' => 'client/mandatory', + 'categories' => array( 'plumbing' ), + 'modes' => array( 'chat' ), + ), + 'client/system' => array( + 'name' => 'client/system', + 'categories' => array( 'system' ), + 'modes' => array( 'system' ), + ), +); + +$resolver = new WP_Agent_Tool_Policy(); + +echo "\n[2] Agent/runtime allow policy narrows optional tools and preserves mandatory provider tools:\n"; +$resolved = $resolver->resolve( + $tools, + array( + 'mode' => 'chat', + 'agent_config' => array( + 'tool_policy' => array( + 'mode' => 'allow', + 'tools' => array( 'client/read' ), + ), + ), + 'tool_policy_providers' => array( + new class() implements WP_Agent_Tool_Access_Policy_Interface { + public function get_tool_policy( array $context ): ?array { + unset( $context ); + return array( + 'mandatory_tools' => array( 'client/mandatory' ), + ); + } + }, + ), + ) +); +$resolved_names = array_keys( $resolved ); +sort( $resolved_names ); +agents_api_smoke_assert_equals( array( 'client/mandatory', 'client/read' ), $resolved_names, 'mandatory provider tools survive allow policy', $failures, $passes ); + +echo "\n[3] Runtime category and mode filters are generic:\n"; +$resolved = $resolver->resolve( + $tools, + array( + 'mode' => 'system', + 'categories' => array( 'system' ), + ) +); +agents_api_smoke_assert_equals( array( 'client/system' ), array_keys( $resolved ), 'system mode category filter returns only matching system tool', $failures, $passes ); + +echo "\n[4] Explicit deny always wins over mandatory providers:\n"; +$resolved = $resolver->resolve( + $tools, + array( + 'mode' => 'chat', + 'deny' => array( 'client/mandatory' ), + 'tool_policy' => array( + 'mode' => 'allow', + 'tools' => array( 'client/read' ), + ), + 'tool_policy_providers' => array( + new class() implements WP_Agent_Tool_Access_Policy_Interface { + public function get_tool_policy( array $context ): ?array { + unset( $context ); + return array( + 'mandatory_tools' => array( 'client/mandatory' ), + ); + } + }, + ), + ) +); +agents_api_smoke_assert_equals( array( 'client/read' ), array_keys( $resolved ), 'explicit deny removes mandatory provider tool', $failures, $passes ); + +agents_api_smoke_finish( 'Tool policy contracts', $failures, $passes );