diff --git a/agents-api.php b/agents-api.php index 94ccde5..9e777e1 100644 --- a/agents-api.php +++ b/agents-api.php @@ -171,6 +171,7 @@ require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-ability-tool-executor.php'; require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-tool-execution-core.php'; require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-tool-source-registry.php'; +require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-tool-mediation-runner.php'; require_once AGENTS_API_PATH . 'src/Context/class-wp-agent-context-authority-tier.php'; require_once AGENTS_API_PATH . 'src/Context/class-wp-agent-context-conflict-kind.php'; require_once AGENTS_API_PATH . 'src/Context/class-wp-agent-context-item.php'; diff --git a/composer.json b/composer.json index 9345491..18b8019 100644 --- a/composer.json +++ b/composer.json @@ -76,6 +76,7 @@ "php tests/conversation-loop-smoke.php", "php tests/provider-turn-adapter-smoke.php", "php tests/ability-tool-executor-smoke.php", + "php tests/tool-mediation-runner-smoke.php", "php tests/conversation-loop-tool-execution-smoke.php", "php tests/conversation-loop-completion-policy-smoke.php", "php tests/conversation-loop-transcript-persister-smoke.php", diff --git a/src/Runtime/class-wp-agent-conversation-loop.php b/src/Runtime/class-wp-agent-conversation-loop.php index 8e81b59..f1faf1a 100644 --- a/src/Runtime/class-wp-agent-conversation-loop.php +++ b/src/Runtime/class-wp-agent-conversation-loop.php @@ -291,22 +291,24 @@ public static function run( array $messages, ?callable $turn_runner = null, arra // When mediation is enabled, the turn runner returns tool_calls // and the loop handles execution. Otherwise, the caller-managed path applies. if ( null !== $tool_executor && $mediation_enabled && isset( $result['tool_calls'] ) && is_array( $result['tool_calls'] ) ) { - $mediation_result = self::mediate_tool_calls( + $mediation_result = WP_Agent_Tool_Mediation_Runner::run( + $messages, self::normalize_assoc_array( $result ), $tool_executor, $tool_declarations, - $completion_policy, - $turn_context, - $turn, - $on_event, - $budgets, - $failure_tracker, - $result_truncator, - $messages, - $pre_tool_mediator, - $tool_results, - $post_tool_diagnostics, - $runtime_tool_store + array( + 'completion_policy' => $completion_policy, + 'turn_context' => $turn_context, + 'turn' => $turn, + 'on_event' => $on_event, + 'budgets' => $budgets, + 'identical_failure_tracker' => $failure_tracker, + 'tool_result_truncator' => $result_truncator, + 'pre_tool_mediator' => $pre_tool_mediator, + 'prior_tool_results' => $tool_results, + 'post_tool_result_diagnostics' => $post_tool_diagnostics, + 'runtime_tool_request_store' => $runtime_tool_store, + ) ); $messages = $mediation_result['messages']; @@ -510,7 +512,7 @@ public static function run( array $messages, ?callable $turn_runner = null, arra * @param WP_Agent_Runtime_Tool_Request_Store|null $runtime_tool_store Optional durable runtime tool request store. * @return array{messages: array>, tool_execution_results: array>, tool_events: array>, tool_audit_events: array>, events: array>, conversation_complete: bool, exceeded_budget: string|null, approval_required: array|null, runtime_tool_pending: array|null, spin_signatures: array} */ - private static function mediate_tool_calls( + public static function mediate_tool_calls( array $result, WP_Agent_Tool_Executor $executor, array $declarations, diff --git a/src/Runtime/class-wp-agent-tool-mediation-runner.php b/src/Runtime/class-wp-agent-tool-mediation-runner.php new file mode 100644 index 0000000..16df3e1 --- /dev/null +++ b/src/Runtime/class-wp-agent-tool-mediation-runner.php @@ -0,0 +1,121 @@ +> $transcript Transcript before this mediated turn. + * @param array $turn_result Turn result with `tool_calls` and optional content/messages. + * @param WP_Agent_Tool_Executor $executor Tool executor adapter. + * @param array> $declarations Tool declarations keyed by name. + * @param array $options Execution policy and observers. + * @return array{messages: array>, tool_execution_results: array>, tool_events: array>, tool_audit_events: array>, events: array>, conversation_complete: bool, exceeded_budget: string|null, approval_required: array|null, runtime_tool_pending: array|null, spin_signatures: array} + */ + public static function run( array $transcript, array $turn_result, WP_Agent_Tool_Executor $executor, array $declarations, array $options = array() ): array { + $turn_context = isset( $options['turn_context'] ) && is_array( $options['turn_context'] ) ? self::normalize_assoc_array( $options['turn_context'] ) : array(); + $turn = isset( $options['turn'] ) && is_int( $options['turn'] ) ? $options['turn'] : 1; + $completion_policy = $options['completion_policy'] ?? null; + $failure_tracker = $options['identical_failure_tracker'] ?? null; + $result_truncator = $options['tool_result_truncator'] ?? null; + $runtime_tool_store = $options['runtime_tool_request_store'] ?? null; + $budgets = self::normalize_budgets( $options['budgets'] ?? array() ); + + return WP_Agent_Conversation_Loop::mediate_tool_calls( + self::normalize_assoc_array( $turn_result ), + $executor, + $declarations, + $completion_policy instanceof WP_Agent_Conversation_Completion_Policy ? $completion_policy : null, + $turn_context, + max( 1, $turn ), + is_callable( $options['on_event'] ?? null ) ? $options['on_event'] : null, + $budgets, + $failure_tracker instanceof WP_Agent_Identical_Failure_Tracker ? $failure_tracker : null, + $result_truncator instanceof WP_Agent_Tool_Result_Truncator ? $result_truncator : null, + WP_Agent_Message::normalize_many( $transcript ), + is_callable( $options['pre_tool_mediator'] ?? null ) ? $options['pre_tool_mediator'] : null, + isset( $options['prior_tool_results'] ) && is_array( $options['prior_tool_results'] ) ? self::normalize_array_list( $options['prior_tool_results'] ) : array(), + is_callable( $options['post_tool_result_diagnostics'] ?? null ) ? $options['post_tool_result_diagnostics'] : null, + $runtime_tool_store instanceof WP_Agent_Runtime_Tool_Request_Store ? $runtime_tool_store : null + ); + } + + /** + * Normalize budget option values. + * + * @param mixed $value Raw budget option. + * @return array Budgets keyed by name. + */ + private static function normalize_budgets( $value ): array { + if ( ! is_array( $value ) ) { + return array(); + } + + $budgets = array(); + foreach ( $value as $budget ) { + if ( $budget instanceof WP_Agent_Iteration_Budget ) { + $budgets[ $budget->name() ] = $budget; + } + } + + return $budgets; + } + + /** + * Normalize arbitrary associative arrays to string-keyed arrays. + * + * @param mixed $value Raw value. + * @return array String-keyed array. + */ + private static function normalize_assoc_array( $value ): array { + if ( ! is_array( $value ) ) { + return array(); + } + + $normalized = array(); + foreach ( $value as $key => $item ) { + if ( is_string( $key ) ) { + $normalized[ $key ] = $item; + } + } + + return $normalized; + } + + /** + * Normalize a list of arrays to a typed list shape. + * + * @param mixed $value Raw value. + * @return array> List of string-keyed arrays. + */ + private static function normalize_array_list( $value ): array { + if ( ! is_array( $value ) ) { + return array(); + } + + $normalized = array(); + foreach ( $value as $item ) { + if ( is_array( $item ) ) { + $normalized[] = self::normalize_assoc_array( $item ); + } + } + + return $normalized; + } +} diff --git a/tests/tool-mediation-runner-smoke.php b/tests/tool-mediation-runner-smoke.php new file mode 100644 index 0000000..c16e328 --- /dev/null +++ b/tests/tool-mediation-runner-smoke.php @@ -0,0 +1,112 @@ +> Executed calls. */ + public array $executed = array(); + + public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definition, array $context = array() ): array { + $this->executed[] = array( + 'tool_call' => $tool_call, + 'context' => $context, + ); + + return array( + 'success' => true, + 'tool_name' => $tool_call['tool_name'], + 'result' => array( + 'echo' => $tool_call['parameters']['text'] ?? '', + ), + ); + } +}; + +$tools = array( + 'client/echo' => array( + 'name' => 'client/echo', + 'source' => 'client', + 'description' => 'Echo text.', + 'parameters' => array( + 'type' => 'object', + 'required' => array( 'text' ), + 'properties' => array( + 'text' => array( 'type' => 'string' ), + 'secret' => array( + 'type' => 'string', + 'x-sensitive' => true, + ), + ), + ), + ), +); + +$events = array(); +$result = AgentsAPI\AI\WP_Agent_Tool_Mediation_Runner::run( + array( array( 'role' => 'user', 'content' => 'echo this' ) ), + array( + 'content' => 'I will call a tool.', + 'tool_calls' => array( + array( + 'id' => 'call_runner_1', + 'name' => 'client/echo', + 'parameters' => array( + 'text' => 'hello', + 'secret' => 'keep-private', + ), + ), + ), + ), + $executor, + $tools, + array( + 'turn' => 2, + 'turn_context' => array( 'run_id' => 'run-123' ), + 'on_event' => static function ( string $event, array $payload ) use ( &$events ): void { + $events[] = array( + 'event' => $event, + 'payload' => $payload, + ); + }, + ) +); + +agents_api_smoke_assert_equals( 1, count( $executor->executed ), 'runner executes one normalized tool call', $failures, $passes ); +agents_api_smoke_assert_equals( 'client/echo', $executor->executed[0]['tool_call']['tool_name'] ?? '', 'executor receives canonical tool name', $failures, $passes ); +agents_api_smoke_assert_equals( 'call_runner_1', $executor->executed[0]['tool_call']['metadata']['tool_call_id'] ?? '', 'executor receives provider tool call id metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'run-123', $executor->executed[0]['context']['run_id'] ?? '', 'executor receives turn context', $failures, $passes ); +agents_api_smoke_assert_equals( 'I will call a tool.', $result['messages'][1]['content'] ?? '', 'runner appends assistant content before tool messages', $failures, $passes ); +agents_api_smoke_assert_equals( 1, count( $result['tool_execution_results'] ), 'runner returns normalized execution result', $failures, $passes ); +agents_api_smoke_assert_equals( '[redacted]', $result['tool_execution_results'][0]['parameters']['secret'] ?? '', 'runner redacts sensitive parameters in public results', $failures, $passes ); +agents_api_smoke_assert_equals( true, str_starts_with( $result['tool_execution_results'][0]['parameters_sha256'] ?? '', 'sha256:' ), 'runner hashes public parameter envelope', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'tool_call', 'tool_result' ), array_column( $result['tool_events'], 'type' ), 'runner returns canonical tool events', $failures, $passes ); +agents_api_smoke_assert_equals( 'call_runner_1', $result['tool_audit_events'][0]['tool_call_id'] ?? '', 'runner returns stable audit event', $failures, $passes ); +agents_api_smoke_assert_equals( true, ! str_contains( wp_json_encode( $result['tool_audit_events'][0] ), 'keep-private' ), 'runner audit event omits raw sensitive parameters', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'tool_call', 'tool_result' ), array_column( $events, 'event' ), 'runner emits observer events', $failures, $passes ); + +if ( ! empty( $failures ) ) { + echo "\nFailures:\n"; + foreach ( $failures as $failure ) { + echo '- ' . $failure . "\n"; + } + exit( 1 ); +} + +echo "\nPassed {$passes} assertions.\n";