diff --git a/src/Runtime/AgentConversationCompaction.php b/src/Runtime/AgentConversationCompaction.php index 463372c..1b8ee47 100644 --- a/src/Runtime/AgentConversationCompaction.php +++ b/src/Runtime/AgentConversationCompaction.php @@ -36,21 +36,24 @@ class AgentConversationCompaction { */ public static function default_policy(): array { return array( - 'enabled' => false, - 'max_messages' => 40, - 'recent_messages' => 12, - 'summary_role' => 'system', - 'summary_prefix' => 'Earlier conversation summary:', - 'summary_model' => '', - 'summary_provider' => '', - 'preserve_tool_boundaries' => true, - 'overflow_archive_enabled' => false, - 'overflow_threshold_bytes' => 0, - 'overflow_retained_messages' => 0, - 'overflow_retained_bytes' => 0, - 'overflow_archive_pointer' => array(), - 'overflow_stub_role' => 'system', - 'overflow_stub_prefix' => 'Earlier conversation archived without summarization.', + 'enabled' => false, + 'max_messages' => 40, + 'recent_messages' => 12, + 'summary_role' => 'system', + 'summary_prefix' => 'Earlier conversation summary:', + 'summary_model' => '', + 'summary_provider' => '', + 'preserve_tool_boundaries' => true, + 'conservation_enabled' => false, + 'minimum_conserved_byte_ratio' => 1.0, + 'fail_on_conservation_failure' => true, + 'overflow_archive_enabled' => false, + 'overflow_threshold_bytes' => 0, + 'overflow_retained_messages' => 0, + 'overflow_retained_bytes' => 0, + 'overflow_archive_pointer' => array(), + 'overflow_stub_role' => 'system', + 'overflow_stub_prefix' => 'Earlier conversation archived without summarization.', ); } @@ -63,21 +66,24 @@ public static function default_policy(): array { public static function normalize_policy( array $policy ): array { $normalized = array_merge( self::default_policy(), $policy ); - $normalized['enabled'] = (bool) $normalized['enabled']; - $normalized['max_messages'] = max( 1, (int) $normalized['max_messages'] ); - $normalized['recent_messages'] = max( 1, (int) $normalized['recent_messages'] ); - $normalized['summary_role'] = self::normalize_string( $normalized['summary_role'], 'system' ); - $normalized['summary_prefix'] = self::normalize_string( $normalized['summary_prefix'], 'Earlier conversation summary:' ); - $normalized['summary_model'] = self::normalize_string( $normalized['summary_model'], '' ); - $normalized['summary_provider'] = self::normalize_string( $normalized['summary_provider'], '' ); - $normalized['preserve_tool_boundaries'] = (bool) $normalized['preserve_tool_boundaries']; - $normalized['overflow_archive_enabled'] = (bool) $normalized['overflow_archive_enabled']; - $normalized['overflow_threshold_bytes'] = max( 0, (int) $normalized['overflow_threshold_bytes'] ); - $normalized['overflow_retained_messages'] = max( 0, (int) $normalized['overflow_retained_messages'] ); - $normalized['overflow_retained_bytes'] = max( 0, (int) $normalized['overflow_retained_bytes'] ); - $normalized['overflow_archive_pointer'] = is_array( $normalized['overflow_archive_pointer'] ) ? $normalized['overflow_archive_pointer'] : array(); - $normalized['overflow_stub_role'] = self::normalize_string( $normalized['overflow_stub_role'], 'system' ); - $normalized['overflow_stub_prefix'] = self::normalize_string( $normalized['overflow_stub_prefix'], 'Earlier conversation archived without summarization.' ); + $normalized['enabled'] = (bool) $normalized['enabled']; + $normalized['max_messages'] = max( 1, (int) $normalized['max_messages'] ); + $normalized['recent_messages'] = max( 1, (int) $normalized['recent_messages'] ); + $normalized['summary_role'] = self::normalize_string( $normalized['summary_role'], 'system' ); + $normalized['summary_prefix'] = self::normalize_string( $normalized['summary_prefix'], 'Earlier conversation summary:' ); + $normalized['summary_model'] = self::normalize_string( $normalized['summary_model'], '' ); + $normalized['summary_provider'] = self::normalize_string( $normalized['summary_provider'], '' ); + $normalized['preserve_tool_boundaries'] = (bool) $normalized['preserve_tool_boundaries']; + $normalized['conservation_enabled'] = (bool) $normalized['conservation_enabled']; + $normalized['minimum_conserved_byte_ratio'] = max( 0.0, (float) $normalized['minimum_conserved_byte_ratio'] ); + $normalized['fail_on_conservation_failure'] = (bool) $normalized['fail_on_conservation_failure']; + $normalized['overflow_archive_enabled'] = (bool) $normalized['overflow_archive_enabled']; + $normalized['overflow_threshold_bytes'] = max( 0, (int) $normalized['overflow_threshold_bytes'] ); + $normalized['overflow_retained_messages'] = max( 0, (int) $normalized['overflow_retained_messages'] ); + $normalized['overflow_retained_bytes'] = max( 0, (int) $normalized['overflow_retained_bytes'] ); + $normalized['overflow_archive_pointer'] = is_array( $normalized['overflow_archive_pointer'] ) ? $normalized['overflow_archive_pointer'] : array(); + $normalized['overflow_stub_role'] = self::normalize_string( $normalized['overflow_stub_role'], 'system' ); + $normalized['overflow_stub_prefix'] = self::normalize_string( $normalized['overflow_stub_prefix'], 'Earlier conversation archived without summarization.' ); if ( $normalized['recent_messages'] >= $normalized['max_messages'] ) { $normalized['recent_messages'] = max( 1, $normalized['max_messages'] - 1 ); @@ -90,8 +96,9 @@ public static function normalize_policy( array $policy ): array { * Compact a transcript before model dispatch. * * The summarizer receives `(array $messages_to_summarize, array $context)` and - * must return a summary string. On failure the original transcript is returned - * unchanged with a `compaction_failed` lifecycle event. + * must return a summary string or an array with `summary` and optional + * `archived_items`. On failure the original transcript is returned unchanged + * with a `compaction_failed` lifecycle event. * * @param array $messages Complete transcript messages. * @param array $policy Compaction policy. @@ -108,20 +115,26 @@ public static function compact( array $messages, array $policy, callable $summar return self::archive_overflow( $source_messages, $normalized_messages, $policy ); } + $original_stats = self::item_stats( $normalized_messages ); + if ( ! $policy['enabled'] || $total_messages <= $policy['max_messages'] ) { - return self::result( $normalized_messages, self::STATUS_SKIPPED, array(), array() ); + return self::result( $normalized_messages, self::STATUS_SKIPPED, self::metadata( $policy, $original_stats, array(), $original_stats ), array() ); } $cutoff = self::select_boundary( $normalized_messages, $policy ); if ( $cutoff <= 0 ) { - return self::result( $normalized_messages, self::STATUS_SKIPPED, array(), array() ); + return self::result( $normalized_messages, self::STATUS_SKIPPED, self::metadata( $policy, $original_stats, array(), $original_stats ), array() ); } + $messages_to_summarize = array_slice( $normalized_messages, 0, $cutoff ); + $retained_messages = array_slice( $normalized_messages, $cutoff ); + $retained_stats = self::item_stats( $retained_messages ); + $summary_context = array( 'policy' => $policy, 'total_messages' => $total_messages, 'compact_count' => $cutoff, - 'retained_count' => $total_messages - $cutoff, + 'retained_count' => count( $retained_messages ), 'boundary' => array( 'compact_until' => $cutoff - 1, 'retain_from' => $cutoff, @@ -131,12 +144,15 @@ public static function compact( array $messages, array $policy, callable $summar $started_event = self::event( self::EVENT_STARTED, $summary_context ); try { - $summary = call_user_func( $summarizer, array_slice( $normalized_messages, 0, $cutoff ), $summary_context ); + $summary_result = call_user_func( $summarizer, $messages_to_summarize, $summary_context ); + $summary = self::summary_text( $summary_result ); + $archived_items = self::archived_items( $summary_result ); + if ( ! is_string( $summary ) || '' === trim( $summary ) ) { throw new \RuntimeException( 'Summary must be a non-empty string.' ); } } catch ( \Throwable $error ) { - $failure_context = $summary_context; + $failure_context = self::metadata( $policy, $original_stats, array(), $retained_stats, array(), $summary_context ); $failure_context['error'] = $error->getMessage(); return self::result( @@ -160,10 +176,23 @@ public static function compact( array $messages, array $policy, callable $summar ) ); - $compacted_messages = array_merge( array( $summary_message ), array_slice( $normalized_messages, $cutoff ) ); - $complete_context = $summary_context; + $compacted_messages = array_merge( array( $summary_message ), $retained_messages ); + $compacted_stats = self::item_stats( array( $summary_message ) ); + $archived_stats = self::item_stats( $archived_items ); + $complete_context = self::metadata( $policy, $original_stats, $compacted_stats, $retained_stats, $archived_stats, $summary_context ); $complete_context['summary_message'] = $summary_message; + if ( self::conservation_failed( $complete_context ) ) { + $complete_context['error'] = 'Compaction conservation check failed.'; + + return self::result( + $normalized_messages, + self::STATUS_FAILED, + $complete_context, + array( $started_event, self::event( self::EVENT_FAILED, $complete_context ) ) + ); + } + return self::result( $compacted_messages, self::STATUS_COMPACTED, @@ -342,6 +371,150 @@ private static function encoded_json( $data ): string { return is_string( $encoded ) ? $encoded : ''; } + /** + * Build generic provenance and conservation metadata. + * + * @param array $policy Normalized policy. + * @param array $original_stats Original item stats. + * @param array $compacted_stats Compacted item stats. + * @param array $retained_stats Retained item stats. + * @param array $archived_stats Archived item stats. + * @param array $extra Extra metadata. + * @return array + */ + private static function metadata( array $policy, array $original_stats, array $compacted_stats = array(), array $retained_stats = array(), array $archived_stats = array(), array $extra = array() ): array { + $empty_stats = array( + 'item_count' => 0, + 'byte_count' => 0, + ); + + $compacted_stats = array_merge( $empty_stats, $compacted_stats ); + $retained_stats = array_merge( $empty_stats, $retained_stats ); + $archived_stats = array_merge( $empty_stats, $archived_stats ); + + $conserved_bytes = $compacted_stats['byte_count'] + $retained_stats['byte_count'] + $archived_stats['byte_count']; + $required_bytes = (int) ceil( $original_stats['byte_count'] * $policy['minimum_conserved_byte_ratio'] ); + $passed = ! $policy['conservation_enabled'] || $conserved_bytes >= $required_bytes; + + $metadata = array_merge( + $extra, + array( + 'policy' => $policy, + 'provenance' => array( + 'original' => $original_stats, + 'compacted' => $compacted_stats, + 'retained' => $retained_stats, + 'archived' => $archived_stats, + ), + 'summarizer' => array( + 'provider' => $policy['summary_provider'], + 'model' => $policy['summary_model'], + ), + 'conservation' => array( + 'enabled' => $policy['conservation_enabled'], + 'minimum_conserved_byte_ratio' => $policy['minimum_conserved_byte_ratio'], + 'required_byte_count' => $required_bytes, + 'conserved_byte_count' => $conserved_bytes, + 'conserved_byte_ratio' => 0 === $original_stats['byte_count'] ? 1.0 : $conserved_bytes / $original_stats['byte_count'], + 'passed' => $passed, + 'failed_closed' => $policy['conservation_enabled'] && $policy['fail_on_conservation_failure'] && ! $passed, + ), + ) + ); + + return $metadata; + } + + /** + * Determine whether compaction should fail because conservation did not pass. + * + * @param array $metadata Compaction metadata. + * @return bool + */ + private static function conservation_failed( array $metadata ): bool { + $conservation = $metadata['conservation'] ?? array(); + return true === ( $conservation['failed_closed'] ?? false ); + } + + /** + * Extract summary text from the summarizer result. + * + * @param mixed $summary_result Summarizer result. + * @return mixed + */ + private static function summary_text( $summary_result ) { + if ( is_array( $summary_result ) && array_key_exists( 'summary', $summary_result ) ) { + return $summary_result['summary']; + } + + return $summary_result; + } + + /** + * Extract optional archived items from the summarizer result. + * + * @param mixed $summary_result Summarizer result. + * @return array + */ + private static function archived_items( $summary_result ): array { + if ( ! is_array( $summary_result ) || ! is_array( $summary_result['archived_items'] ?? null ) ) { + return array(); + } + + return array_values( $summary_result['archived_items'] ); + } + + /** + * Count items and content bytes for generic provenance metadata. + * + * @param array $items Items. + * @return array{item_count: int, byte_count: int} + */ + private static function item_stats( array $items ): array { + $bytes = 0; + + foreach ( $items as $item ) { + $bytes += self::item_bytes( $item ); + } + + return array( + 'item_count' => count( $items ), + 'byte_count' => $bytes, + ); + } + + /** + * Count bytes for an item's durable content. + * + * @param mixed $item Item. + * @return int + */ + private static function item_bytes( $item ): int { + $content = is_array( $item ) && array_key_exists( 'content', $item ) ? $item['content'] : $item; + + if ( is_string( $content ) ) { + return strlen( $content ); + } + + $encoded = self::json_encode( $content ); + return false === $encoded ? 0 : strlen( $encoded ); + } + + /** + * Encode data with a pure-PHP fallback for smoke tests. + * + * @param mixed $data Data. + * @return string|false + */ + private static function json_encode( $data ) { + if ( function_exists( 'wp_json_encode' ) ) { + return wp_json_encode( $data ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Pure-PHP smoke tests run without WordPress loaded. + return json_encode( $data ); + } + /** * Build a lifecycle event payload for streaming clients. * diff --git a/tests/conversation-compaction-smoke.php b/tests/conversation-compaction-smoke.php index 6795faa..ea72d6a 100644 --- a/tests/conversation-compaction-smoke.php +++ b/tests/conversation-compaction-smoke.php @@ -25,6 +25,13 @@ 'recent_messages' => 2, ); +$conservation_policy = array_merge( + $policy, + array( + 'conservation_enabled' => true, + ) +); + $messages = array( array( 'role' => 'user', 'content' => 'one' ), array( 'role' => 'assistant', 'content' => 'two' ), @@ -55,19 +62,74 @@ static function (): string { $compacted = AgentsAPI\AI\AgentConversationCompaction::compact( $long_messages, - $policy, - static function ( array $messages_to_summarize, array $context ): string { - return 'Summarized ' . count( $messages_to_summarize ) . ' of ' . $context['total_messages'] . ' messages.'; + $conservation_policy, + static function ( array $messages_to_summarize, array $context ): array { + return array( + 'summary' => 'Summarized ' . count( $messages_to_summarize ) . ' of ' . $context['total_messages'] . ' messages: one two three four.', + 'archived_items' => $messages_to_summarize, + ); } ); agents_api_smoke_assert_equals( AgentsAPI\AI\AgentConversationCompaction::STATUS_COMPACTED, $compacted['metadata']['compaction']['status'], 'long transcript is compacted', $failures, $passes ); agents_api_smoke_assert_equals( 3, count( $compacted['messages'] ), 'compacted transcript contains summary plus retained messages', $failures, $passes ); agents_api_smoke_assert_equals( 'system', $compacted['messages'][0]['role'], 'summary message uses policy role', $failures, $passes ); agents_api_smoke_assert_equals( 4, $compacted['messages'][0]['metadata']['agents_api_compaction']['compacted_message_count'], 'summary metadata records compacted boundary', $failures, $passes ); +agents_api_smoke_assert_equals( 6, $compacted['metadata']['compaction']['provenance']['original']['item_count'], 'metadata records original item count', $failures, $passes ); +agents_api_smoke_assert_equals( 1, $compacted['metadata']['compaction']['provenance']['compacted']['item_count'], 'metadata records compacted item count', $failures, $passes ); +agents_api_smoke_assert_equals( 2, $compacted['metadata']['compaction']['provenance']['retained']['item_count'], 'metadata records retained item count', $failures, $passes ); +agents_api_smoke_assert_equals( 4, $compacted['metadata']['compaction']['provenance']['archived']['item_count'], 'metadata records archived item count', $failures, $passes ); +agents_api_smoke_assert_equals( true, 0 < $compacted['metadata']['compaction']['provenance']['original']['byte_count'], 'metadata records original byte count', $failures, $passes ); +agents_api_smoke_assert_equals( true, 0 < $compacted['metadata']['compaction']['provenance']['compacted']['byte_count'], 'metadata records compacted byte count', $failures, $passes ); +agents_api_smoke_assert_equals( true, 0 < $compacted['metadata']['compaction']['provenance']['retained']['byte_count'], 'metadata records retained byte count', $failures, $passes ); +agents_api_smoke_assert_equals( true, 0 < $compacted['metadata']['compaction']['provenance']['archived']['byte_count'], 'metadata records archived byte count', $failures, $passes ); +agents_api_smoke_assert_equals( true, $compacted['metadata']['compaction']['conservation']['passed'], 'healthy compaction passes conservation', $failures, $passes ); agents_api_smoke_assert_equals( AgentsAPI\AI\AgentConversationCompaction::EVENT_STARTED, $compacted['events'][0]['type'], 'compaction start event is emitted', $failures, $passes ); agents_api_smoke_assert_equals( AgentsAPI\AI\AgentConversationCompaction::EVENT_COMPLETED, $compacted['events'][1]['type'], 'compaction completed event is emitted', $failures, $passes ); -echo "\n[3] Summarizer failures retain the original transcript:\n"; +echo "\n[3] Lossy compaction fails closed when conservation is enabled:\n"; +$durable_messages = array( + array( 'role' => 'user', 'content' => str_repeat( 'alpha ', 40 ) ), + array( 'role' => 'assistant', 'content' => str_repeat( 'bravo ', 40 ) ), + array( 'role' => 'user', 'content' => str_repeat( 'charlie ', 40 ) ), + array( 'role' => 'assistant', 'content' => str_repeat( 'delta ', 40 ) ), +); +$lossy_failed = AgentsAPI\AI\AgentConversationCompaction::compact( + $durable_messages, + array( + 'enabled' => true, + 'conservation_enabled' => true, + 'max_messages' => 3, + 'recent_messages' => 1, + 'minimum_conserved_byte_ratio' => 1.0, + ), + static function (): string { + return 'tiny'; + } +); +agents_api_smoke_assert_equals( AgentsAPI\AI\AgentConversationCompaction::STATUS_FAILED, $lossy_failed['metadata']['compaction']['status'], 'lossy compaction is rejected', $failures, $passes ); +agents_api_smoke_assert_equals( count( $durable_messages ), count( $lossy_failed['messages'] ), 'lossy rejection keeps original transcript length', $failures, $passes ); +agents_api_smoke_assert_equals( false, $lossy_failed['metadata']['compaction']['conservation']['passed'], 'lossy rejection records failed conservation', $failures, $passes ); +agents_api_smoke_assert_equals( true, $lossy_failed['metadata']['compaction']['conservation']['failed_closed'], 'lossy rejection records fail-closed state', $failures, $passes ); + +echo "\n[4] Conservation can be disabled for intentionally lossy compaction:\n"; +$lossy_allowed = AgentsAPI\AI\AgentConversationCompaction::compact( + $durable_messages, + array( + 'enabled' => true, + 'conservation_enabled' => false, + 'max_messages' => 3, + 'recent_messages' => 1, + 'minimum_conserved_byte_ratio' => 1.0, + ), + static function (): string { + return 'tiny'; + } +); +agents_api_smoke_assert_equals( AgentsAPI\AI\AgentConversationCompaction::STATUS_COMPACTED, $lossy_allowed['metadata']['compaction']['status'], 'disabled conservation allows lossy compaction', $failures, $passes ); +agents_api_smoke_assert_equals( 2, count( $lossy_allowed['messages'] ), 'disabled conservation returns compacted transcript', $failures, $passes ); +agents_api_smoke_assert_equals( false, $lossy_allowed['metadata']['compaction']['conservation']['enabled'], 'disabled conservation records opt-out', $failures, $passes ); + +echo "\n[5] Summarizer failures retain the original transcript:\n"; $failed = AgentsAPI\AI\AgentConversationCompaction::compact( $long_messages, $policy, @@ -79,7 +141,7 @@ static function (): string { agents_api_smoke_assert_equals( count( $long_messages ), count( $failed['messages'] ), 'summarizer failure keeps original transcript length', $failures, $passes ); agents_api_smoke_assert_equals( AgentsAPI\AI\AgentConversationCompaction::EVENT_FAILED, $failed['events'][1]['type'], 'summarizer failure emits failed event', $failures, $passes ); -echo "\n[4] Boundary selection does not split tool-call/tool-result pairs:\n"; +echo "\n[6] Boundary selection does not split tool-call/tool-result pairs:\n"; $tool_messages = array( array( 'role' => 'user', 'content' => 'question' ), AgentsAPI\AI\AgentMessageEnvelope::toolCall( 'call weather', 'weather', array( 'city' => 'New York' ), 1 ), @@ -98,7 +160,7 @@ static function (): string { ); agents_api_smoke_assert_equals( 1, $boundary, 'boundary moves before retained tool result', $failures, $passes ); -echo "\n[5] WP_Agent exposes declarative compaction capability and policy:\n"; +echo "\n[7] WP_Agent exposes declarative compaction capability and policy:\n"; $agent = new WP_Agent( 'compacting-agent', array(