diff --git a/docs/runtime-and-tools.md b/docs/runtime-and-tools.md index 20e6eb7..9afe750 100644 --- a/docs/runtime-and-tools.md +++ b/docs/runtime-and-tools.md @@ -134,12 +134,55 @@ $tool = AgentsAPI\AI\Tools\WP_Agent_Tool_Declaration::normalize( ), 'executor' => 'client', 'scope' => 'run', + 'runtime' => array( + 'duplicate_policy' => 'repeatable', + 'completion_signal' => 'progress', + ), ) ); ``` Invalid declarations produce machine-readable invalid field names through `validate()` or an `InvalidArgumentException` from `normalize()`. +### Tool runtime metadata + +Tool declarations and tool results may include optional `runtime` metadata. This metadata is product-neutral execution policy data for the agent loop and host runtime; it is not a concrete tool implementation detail and should not encode product-specific tool names. + +Canonical keys: + +| Key | Value | Meaning | +| --- | --- | --- | +| `duplicate_policy` | `repeatable` | The same tool may be called repeatedly with the same parameters when the host considers that safe. | +| `completion_signal` | `progress` | A successful tool result is progress toward completion and may be used by caller-owned completion policy. | + +The substrate treats `runtime` as a JSON-friendly associative array. It preserves scalar and nested array values with string keys, drops unsupported values, and leaves product-specific interpretation to callers. + +When the conversation loop mediates tool calls, declaration runtime metadata is propagated into the normalized tool result and exposed on the corresponding `tool_execution_results[]` entry: + +```php +array( + 'tool_name' => 'client/search_docs', + 'tool_call_id' => 'call_123', + 'parameters' => array( 'query' => 'runtime metadata' ), + 'result' => array( + 'success' => true, + 'tool_name' => 'client/search_docs', + 'result' => array( 'matches' => array() ), + 'runtime' => array( + 'duplicate_policy' => 'repeatable', + 'completion_signal' => 'progress', + ), + ), + 'runtime' => array( + 'duplicate_policy' => 'repeatable', + 'completion_signal' => 'progress', + ), + 'turn_count' => 1, +) +``` + +If a concrete executor returns its own `runtime` metadata, the normalized result merges it over declaration metadata for that execution result. This lets the declaration advertise generic defaults while an executor refines result-scoped signals without changing the declaration. + ## Tool execution core `WP_Agent_Tool_Execution_Core` mediates calls without owning any concrete tool implementation. diff --git a/src/Runtime/class-wp-agent-conversation-loop.php b/src/Runtime/class-wp-agent-conversation-loop.php index 740637d..00ba69c 100644 --- a/src/Runtime/class-wp-agent-conversation-loop.php +++ b/src/Runtime/class-wp-agent-conversation-loop.php @@ -391,7 +391,7 @@ private static function mediate_tool_calls( ) ); // Build the tool_execution_results entry. - $tool_execution_results[] = array( + $execution_result = array( 'tool_name' => $tool_name, 'tool_call_id' => $tool_call_id, 'result' => $exec_result, @@ -399,6 +399,13 @@ private static function mediate_tool_calls( 'turn_count' => $turn, ); + $runtime = isset( $exec_result['runtime'] ) && is_array( $exec_result['runtime'] ) ? $exec_result['runtime'] : array(); + if ( ! empty( $runtime ) ) { + $execution_result['runtime'] = $runtime; + } + + $tool_execution_results[] = $execution_result; + $tool_audit_events[] = self::tool_audit_event( $tool_name, is_array( $parameters ) ? $parameters : array(), diff --git a/src/Runtime/class-wp-agent-conversation-result.php b/src/Runtime/class-wp-agent-conversation-result.php index e2a955f..eac13ad 100644 --- a/src/Runtime/class-wp-agent-conversation-result.php +++ b/src/Runtime/class-wp-agent-conversation-result.php @@ -91,6 +91,10 @@ public static function normalize( array $result ): array { throw self::invalid( $path . '.is_handler_tool', 'must be a boolean when present' ); } + if ( array_key_exists( 'runtime', $tool_result ) && ! is_array( $tool_result['runtime'] ) ) { + throw self::invalid( $path . '.runtime', 'must be an array when present' ); + } + if ( ! array_key_exists( 'turn_count', $tool_result ) || ! is_int( $tool_result['turn_count'] ) ) { throw self::invalid( $path . '.turn_count', 'must be an integer' ); } diff --git a/src/Tools/class-wp-agent-tool-declaration.php b/src/Tools/class-wp-agent-tool-declaration.php index 4d3625b..f459496 100644 --- a/src/Tools/class-wp-agent-tool-declaration.php +++ b/src/Tools/class-wp-agent-tool-declaration.php @@ -23,6 +23,16 @@ class WP_Agent_Tool_Declaration { public const EXECUTOR_CLIENT = 'client'; public const SCOPE_RUN = 'run'; + /** + * Generic runtime metadata key for duplicate-call behavior. + */ + public const RUNTIME_DUPLICATE_POLICY = 'duplicate_policy'; + + /** + * Generic runtime metadata key for progress/completion signaling. + */ + public const RUNTIME_COMPLETION_SIGNAL = 'completion_signal'; + /** * Normalize a runtime tool declaration or throw a field-scoped error. * @@ -45,7 +55,7 @@ public static function normalize( array $declaration ): array { $name = (string) $declaration['name']; $source = self::sourceFromName( $name ); - return array( + $normalized = array( 'name' => $name, 'source' => $source, 'description' => trim( (string) $declaration['description'] ), @@ -53,6 +63,13 @@ public static function normalize( array $declaration ): array { 'executor' => self::EXECUTOR_CLIENT, 'scope' => self::SCOPE_RUN, ); + + $runtime = self::normalizeRuntimeMetadata( $declaration['runtime'] ?? array() ); + if ( ! empty( $runtime ) ) { + $normalized['runtime'] = $runtime; + } + + return $normalized; } /** @@ -105,9 +122,78 @@ public static function validate( array $declaration ): array { $errors[] = 'scope'; } + if ( isset( $declaration['runtime'] ) && ! is_array( $declaration['runtime'] ) ) { + $errors[] = 'runtime'; + } + return array_values( array_unique( $errors ) ); } + /** + * Normalize optional product-neutral runtime metadata. + * + * Runtime metadata is a JSON-friendly object used by agent loops and hosts to + * make generic execution decisions without hardcoding product tool names. The + * canonical keys are `duplicate_policy` and `completion_signal`, but callers + * may include additional product-neutral scalar/list values for future policy. + * + * @param mixed $runtime Raw runtime metadata. + * @return array Normalized runtime metadata. + */ + public static function normalizeRuntimeMetadata( $runtime ): array { + if ( ! is_array( $runtime ) ) { + return array(); + } + + $normalized = array(); + foreach ( $runtime as $key => $value ) { + if ( ! is_string( $key ) || '' === $key ) { + continue; + } + + $normalized_value = self::normalizeRuntimeMetadataValue( $value ); + if ( null === $normalized_value ) { + continue; + } + + $normalized[ $key ] = $normalized_value; + } + + return $normalized; + } + + /** + * Normalize one JSON-friendly runtime metadata value. + * + * @param mixed $value Raw metadata value. + * @return mixed|null Normalized value, or null when unsupported. + */ + private static function normalizeRuntimeMetadataValue( $value ) { + if ( is_string( $value ) || is_int( $value ) || is_float( $value ) || is_bool( $value ) ) { + return $value; + } + + if ( ! is_array( $value ) ) { + return null; + } + + $normalized = array(); + foreach ( $value as $key => $item ) { + $normalized_item = self::normalizeRuntimeMetadataValue( $item ); + if ( null === $normalized_item ) { + continue; + } + + if ( is_string( $key ) ) { + $normalized[ $key ] = $normalized_item; + } else { + $normalized[] = $normalized_item; + } + } + + return $normalized; + } + /** * Build a namespaced runtime tool name. * diff --git a/src/Tools/class-wp-agent-tool-execution-core.php b/src/Tools/class-wp-agent-tool-execution-core.php index 5b97556..639d828 100644 --- a/src/Tools/class-wp-agent-tool-execution-core.php +++ b/src/Tools/class-wp-agent-tool-execution-core.php @@ -34,6 +34,8 @@ public function prepareWP_Agent_Tool_Call( string $tool_name, array $tool_parame ); } + $runtime = WP_Agent_Tool_Declaration::normalizeRuntimeMetadata( $tool_definition['runtime'] ?? array() ); + $parameters = WP_Agent_Tool_Parameters::buildParameters( $tool_parameters, $context, $tool_definition ); $validation = WP_Agent_Tool_Parameters::validateRequiredParameters( $parameters, $tool_definition ); if ( ! $validation['valid'] ) { @@ -45,7 +47,8 @@ public function prepareWP_Agent_Tool_Call( string $tool_name, array $tool_parame array( 'error_type' => 'missing_required_parameters', 'missing_parameters' => $validation['missing'], - ) + ), + $runtime ) ); } @@ -84,9 +87,19 @@ public function executePreparedTool( array $tool_call, array $tool_definition, W try { $result = $executor->executeWP_Agent_Tool_Call( $tool_call, $tool_definition, $context ); } catch ( \Throwable $throwable ) { - return WP_Agent_Tool_Result::error( $tool_call['tool_name'], $throwable->getMessage(), array( 'error_type' => 'executor_exception' ) ); + return WP_Agent_Tool_Result::error( + $tool_call['tool_name'], + $throwable->getMessage(), + array( 'error_type' => 'executor_exception' ), + WP_Agent_Tool_Declaration::normalizeRuntimeMetadata( $tool_definition['runtime'] ?? array() ) + ); } + $runtime = array_merge( + WP_Agent_Tool_Declaration::normalizeRuntimeMetadata( $tool_definition['runtime'] ?? array() ), + WP_Agent_Tool_Declaration::normalizeRuntimeMetadata( $result['runtime'] ?? array() ) + ); + if ( ! array_key_exists( 'success', $result ) ) { $result = array( 'success' => true, @@ -96,6 +109,9 @@ public function executePreparedTool( array $tool_call, array $tool_definition, W } $result['tool_name'] = is_string( $result['tool_name'] ?? null ) ? $result['tool_name'] : $tool_call['tool_name']; + if ( ! empty( $runtime ) ) { + $result['runtime'] = $runtime; + } return WP_Agent_Tool_Result::normalize( $result ); } diff --git a/src/Tools/class-wp-agent-tool-result.php b/src/Tools/class-wp-agent-tool-result.php index 2ef7cc6..f760b5d 100644 --- a/src/Tools/class-wp-agent-tool-result.php +++ b/src/Tools/class-wp-agent-tool-result.php @@ -22,13 +22,14 @@ class WP_Agent_Tool_Result { * @param array $metadata Optional result metadata. * @return array */ - public static function success( string $tool_name, $result, array $metadata = array() ): array { + public static function success( string $tool_name, $result, array $metadata = array(), array $runtime = array() ): array { return self::normalize( array( 'success' => true, 'tool_name' => $tool_name, 'result' => $result, 'metadata' => $metadata, + 'runtime' => $runtime, ) ); } @@ -41,13 +42,14 @@ public static function success( string $tool_name, $result, array $metadata = ar * @param array $metadata Optional result metadata. * @return array */ - public static function error( string $tool_name, string $error, array $metadata = array() ): array { + public static function error( string $tool_name, string $error, array $metadata = array(), array $runtime = array() ): array { return self::normalize( array( 'success' => false, 'tool_name' => $tool_name, 'error' => $error, 'metadata' => $metadata, + 'runtime' => $runtime, ) ); } @@ -70,12 +72,18 @@ public static function normalize( array $result ): array { $metadata = array(); } + $runtime = WP_Agent_Tool_Declaration::normalizeRuntimeMetadata( $result['runtime'] ?? array() ); + $normalized = array( 'success' => $success, 'tool_name' => $tool_name, 'metadata' => $metadata, ); + if ( ! empty( $runtime ) ) { + $normalized['runtime'] = $runtime; + } + if ( $success ) { $normalized['result'] = $result['result'] ?? array(); return $normalized; diff --git a/tests/conversation-loop-tool-execution-smoke.php b/tests/conversation-loop-tool-execution-smoke.php index 9270c17..9fc1196 100644 --- a/tests/conversation-loop-tool-execution-smoke.php +++ b/tests/conversation-loop-tool-execution-smoke.php @@ -52,6 +52,10 @@ public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definit ), 'executor' => 'client', 'scope' => 'run', + 'runtime' => array( + 'duplicate_policy' => 'repeatable', + 'completion_signal' => 'progress', + ), ), ); @@ -88,6 +92,8 @@ static function ( array $messages, array $context ): array { agents_api_smoke_assert_equals( 'client/summarize', $result['tool_execution_results'][0]['tool_name'], 'tool execution result has correct tool name', $failures, $passes ); agents_api_smoke_assert_equals( 'call_123', $result['tool_execution_results'][0]['tool_call_id'] ?? '', 'tool execution result preserves provider tool call id', $failures, $passes ); agents_api_smoke_assert_equals( 'HELLO WORLD', $result['tool_execution_results'][0]['result']['result']['summary'], 'tool execution result carries executor payload', $failures, $passes ); + agents_api_smoke_assert_equals( 'repeatable', $result['tool_execution_results'][0]['runtime']['duplicate_policy'] ?? '', 'tool execution result exposes duplicate policy runtime metadata', $failures, $passes ); + agents_api_smoke_assert_equals( 'progress', $result['tool_execution_results'][0]['result']['runtime']['completion_signal'] ?? '', 'tool result preserves completion signal runtime metadata', $failures, $passes ); agents_api_smoke_assert_equals( 1, count( $result['tool_audit_events'] ), 'result contains one tool audit event', $failures, $passes ); agents_api_smoke_assert_equals( 'tool_call', $result['tool_audit_events'][0]['type'], 'tool audit event has stable type', $failures, $passes ); agents_api_smoke_assert_equals( 'client/summarize', $result['tool_audit_events'][0]['tool_name'], 'tool audit event has correct tool name', $failures, $passes ); diff --git a/tests/tool-runtime-smoke.php b/tests/tool-runtime-smoke.php index a748621..d7c922f 100644 --- a/tests/tool-runtime-smoke.php +++ b/tests/tool-runtime-smoke.php @@ -33,11 +33,19 @@ ), 'executor' => 'client', 'scope' => 'run', + 'runtime' => array( + 'duplicate_policy' => 'repeatable', + 'completion_signal' => 'progress', + 'unsupported' => new stdClass(), + ), ) ); agents_api_smoke_assert_equals( 'client/choose_post', $declaration['name'], 'runtime declaration keeps namespaced name', $failures, $passes ); agents_api_smoke_assert_equals( 'client', $declaration['source'], 'runtime declaration records source', $failures, $passes ); agents_api_smoke_assert_equals( 'run', $declaration['scope'], 'runtime declaration records run scope', $failures, $passes ); +agents_api_smoke_assert_equals( 'repeatable', $declaration['runtime']['duplicate_policy'] ?? '', 'runtime declaration preserves duplicate policy metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'progress', $declaration['runtime']['completion_signal'] ?? '', 'runtime declaration preserves completion signal metadata', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'unsupported', $declaration['runtime'] ?? array() ), 'runtime declaration drops unsupported metadata values', $failures, $passes ); $registry = new AgentsAPI\AI\Tools\WP_Agent_Tool_Source_Registry(); $registry->registerSource( @@ -57,6 +65,10 @@ static function () { // this declaration, a `text` key in context never satisfies the // required parameter — keeps required-arg sourcing auditable. 'client_context_bindings' => array( 'text' ), + 'runtime' => array( + 'duplicate_policy' => 'repeatable', + 'completion_signal' => 'progress', + ), ), ); } @@ -199,5 +211,32 @@ public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definit agents_api_smoke_assert_equals( 'local/summarize', $result['tool_name'], 'mediated result records tool name', $failures, $passes ); agents_api_smoke_assert_equals( 'HELLO', $result['result']['summary'], 'mediated result carries adapter payload', $failures, $passes ); agents_api_smoke_assert_equals( 'req-123', $result['result']['request_id'], 'adapter receives merged parameters', $failures, $passes ); +agents_api_smoke_assert_equals( 'repeatable', $result['runtime']['duplicate_policy'] ?? '', 'mediated result preserves declaration duplicate policy runtime metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'progress', $result['runtime']['completion_signal'] ?? '', 'mediated result preserves declaration completion signal runtime metadata', $failures, $passes ); + +$result_override_adapter = new class() implements AgentsAPI\AI\Tools\WP_Agent_Tool_Executor { + public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definition, array $context = array() ): array { + unset( $tool_definition, $context ); + + return array( + 'success' => true, + 'tool_name' => $tool_call['tool_name'], + 'result' => array( 'summary' => 'OK' ), + 'runtime' => array( + 'completion_signal' => 'complete', + ), + ); + } +}; + +$result_override = $executor->executeTool( + 'local/summarize', + array( 'text' => 'hello' ), + $tools, + $result_override_adapter, + array() +); +agents_api_smoke_assert_equals( 'repeatable', $result_override['runtime']['duplicate_policy'] ?? '', 'result runtime keeps declaration metadata when executor adds runtime', $failures, $passes ); +agents_api_smoke_assert_equals( 'complete', $result_override['runtime']['completion_signal'] ?? '', 'executor runtime metadata can refine result runtime metadata', $failures, $passes ); agents_api_smoke_finish( 'Agents API tool runtime', $failures, $passes );