diff --git a/inc/Engine/AI/Compaction/ConversationCompactionPolicyResolver.php b/inc/Engine/AI/Compaction/ConversationCompactionPolicyResolver.php new file mode 100644 index 000000000..136383222 --- /dev/null +++ b/inc/Engine/AI/Compaction/ConversationCompactionPolicyResolver.php @@ -0,0 +1,152 @@ + false`, which the substrate treats as a no-op. + * + * @param array $args { + * Resolution arguments describing the request. + * + * @type int|null $agent_id Acting agent ID for per-agent policy. + * @type array $modes Active agent mode slugs (for the filter). + * @type array $overrides Optional policy overrides merged last + * (for example a resolved summary model). + * } + * @return array Normalized compaction policy. + */ + public function resolve( array $args ): array { + $agent_id = isset( $args['agent_id'] ) ? (int) $args['agent_id'] : 0; + $modes = is_array( $args['modes'] ?? null ) ? $args['modes'] : array(); + $overrides = is_array( $args['overrides'] ?? null ) ? $args['overrides'] : array(); + + $agent_policy = $agent_id > 0 ? $this->getAgentCompactionPolicy( $agent_id ) : null; + + $policy = WP_Agent_Conversation_Compaction::default_policy(); + if ( is_array( $agent_policy ) ) { + $policy = array_merge( $policy, $agent_policy ); + } + if ( ! empty( $overrides ) ) { + $policy = array_merge( $policy, $overrides ); + } + + /** + * Filter the resolved conversation compaction policy. + * + * Lets eval/training runners or product code force-enable, force-disable, + * or retune compaction for a given request without editing agent config. + * + * @param array $policy Resolved (pre-normalization) policy. + * @param array $args Resolution arguments. + */ + $policy = apply_filters( 'datamachine_resolved_conversation_compaction_policy', $policy, $args ); + + // Normalize through the substrate contract so the loop always receives a + // well-formed policy with safe defaults applied. + $normalized = WP_Agent_Conversation_Compaction::normalize_policy( is_array( $policy ) ? $policy : array() ); + + unset( $modes ); // Reserved for future mode-scoped resolution; kept for filter parity. + + return $normalized; + } + + /** + * Read an agent's conversation compaction policy from agent_config. + * + * Returns null when the agent does not exist, has not opted in, or the + * configured policy is structurally invalid. Mirrors the null-for-no-op + * pattern used by the sibling policy resolvers. + * + * Opt-in is satisfied by either an explicit `conversation_compaction_policy` + * array with `enabled => true`, or the `supports_conversation_compaction` + * capability flag (which enables compaction with substrate defaults). + * + * @param int $agent_id Agent ID. + * @return array|null Raw policy overrides, or null for no-op. + */ + public function getAgentCompactionPolicy( int $agent_id ): ?array { + if ( $agent_id <= 0 ) { + return null; + } + + $agents_repo = new Agents(); + $agent = $agents_repo->get_agent( $agent_id ); + + if ( ! $agent ) { + return null; + } + + $config = is_array( $agent['agent_config'] ?? null ) ? $agent['agent_config'] : array(); + + $supports = ! empty( $config['supports_conversation_compaction'] ); + $policy = is_array( $config['conversation_compaction_policy'] ?? null ) + ? $config['conversation_compaction_policy'] + : array(); + + // No opt-in signal at all: leave compaction disabled (no-op). + if ( ! $supports && empty( $policy ) ) { + return null; + } + + // The capability flag, when present, enables compaction unless the policy + // explicitly turns it off. An explicit policy `enabled` key always wins. + if ( ! array_key_exists( 'enabled', $policy ) ) { + $policy['enabled'] = $supports; + } + + // A policy that resolves to disabled is a no-op; report null so the + // resolver falls back to the disabled default cleanly. + if ( empty( $policy['enabled'] ) ) { + return null; + } + + return $policy; + } +} diff --git a/inc/Engine/AI/Compaction/ConversationCompactionSummarizer.php b/inc/Engine/AI/Compaction/ConversationCompactionSummarizer.php new file mode 100644 index 000000000..5b1c71a9a --- /dev/null +++ b/inc/Engine/AI/Compaction/ConversationCompactionSummarizer.php @@ -0,0 +1,188 @@ + $loop_payload Cleaned loop payload (carries agent_id). + * @param array $policy Resolved compaction policy (may pin model/provider). + * @return array{provider:string,model:string} + */ + public static function resolveSummaryModel( array $loop_payload, array $policy ): array { + $agent_id = (int) ( $loop_payload['agent_id'] ?? 0 ); + + // An explicit policy provider/model pin wins when both are present. + $policy_provider = trim( (string) ( $policy['summary_provider'] ?? '' ) ); + $policy_model = trim( (string) ( $policy['summary_model'] ?? '' ) ); + if ( '' !== $policy_provider && '' !== $policy_model ) { + $resolved = array( + 'provider' => $policy_provider, + 'model' => $policy_model, + ); + } else { + $resolved = PluginSettings::resolveModelForAgentMode( $agent_id, self::SUMMARY_MODE ); + } + + /** + * Filter the provider/model used for conversation compaction summaries. + * + * @param array{provider:string,model:string} $resolved Resolved provider/model. + * @param array $loop_payload Cleaned loop payload. + * @param array $policy Resolved compaction policy. + */ + $resolved = apply_filters( + 'datamachine_conversation_compaction_summary_model', + $resolved, + $loop_payload, + $policy + ); + + return array( + 'provider' => (string) ( $resolved['provider'] ?? '' ), + 'model' => (string) ( $resolved['model'] ?? '' ), + ); + } + + /** + * Build the summarizer callable for the conversation loop. + * + * Returns a callable matching the Agents API contract: + * `(array $messages_to_summarize, array $context): string`. The callable + * returns a non-empty summary string on success. On any failure it throws, + * which the substrate catches and converts into a `compaction_failed` + * lifecycle event while retaining the original transcript unchanged. + * + * @param array $loop_payload Cleaned loop payload. + * @param array $policy Resolved compaction policy. + * @return callable Summarizer callable. + */ + public static function build( array $loop_payload, array $policy ): callable { + return static function ( array $messages_to_summarize, array $context ) use ( $loop_payload, $policy ): string { + $model = self::resolveSummaryModel( $loop_payload, $policy ); + if ( '' === $model['provider'] || '' === $model['model'] ) { + throw new \RuntimeException( 'No model resolvable for conversation compaction summarization.' ); + } + + $prompt = self::buildPrompt( $messages_to_summarize, $context ); + $messages = array( + WP_Agent_Message::text( 'user', $prompt ), + ); + + // Drive a single tool-free turn. The summarization call deliberately + // passes NO compaction policy/summarizer of its own, so it cannot + // recurse into compaction. System mode keeps it non-interactive. + $summary_payload = array( + 'calling_user_id' => 0, + 'task_type' => 'conversation_compaction_summary', + 'persist_transcript' => false, + 'agent_id' => (int) ( $loop_payload['agent_id'] ?? 0 ), + 'compaction_summarize' => true, + ); + + $response = datamachine_run_conversation( + $messages, + array(), + $model['provider'], + $model['model'], + array( self::SUMMARY_MODE ), + $summary_payload, + 1, + true + ); + + $summary = trim( (string) ( $response['final_content'] ?? '' ) ); + if ( '' === $summary ) { + $error = is_string( $response['error'] ?? null ) ? trim( (string) $response['error'] ) : ''; + throw new \RuntimeException( + '' !== $error + ? 'Conversation compaction summarizer produced no summary: ' . esc_html( $error ) + : 'Conversation compaction summarizer produced an empty summary.' + ); + } + + return $summary; + }; + } + + /** + * Render the messages-to-summarize into a summarization prompt. + * + * @param array> $messages_to_summarize Earlier transcript slice. + * @param array $context Compaction context. + * @return string Prompt text. + */ + private static function buildPrompt( array $messages_to_summarize, array $context ): string { + $lines = array(); + foreach ( WP_Agent_Message::to_provider_messages( $messages_to_summarize ) as $message ) { + $role = (string) ( $message['role'] ?? 'unknown' ); + $content = $message['content'] ?? ''; + if ( is_array( $content ) ) { + $content = (string) wp_json_encode( $content ); + } + $content = trim( (string) $content ); + if ( '' === $content ) { + continue; + } + $lines[] = strtoupper( $role ) . ': ' . $content; + } + + $transcript = implode( "\n\n", $lines ); + + $total = (int) ( $context['total_messages'] ?? count( $messages_to_summarize ) ); + $compact = (int) ( $context['compact_count'] ?? count( $messages_to_summarize ) ); + $retained = (int) ( $context['retained_count'] ?? 0 ); + + $instructions = sprintf( + 'You are compacting the earlier part of an ongoing assistant conversation to keep it within its context budget. ' + . "Summarize the following %d earlier message(s) (of %d total; the most recent %d remain verbatim and are NOT shown here).\n\n" + . 'Preserve every durable fact, decision, constraint, identifier, file path, open question, and any state the assistant must remember to continue correctly. ' + . 'Do not invent information. Do not include pleasantries. Write a dense, faithful summary in plain prose or compact bullet points. ' + . 'Return ONLY the summary text with no preamble.', + $compact, + $total, + $retained + ); + + return $instructions . "\n\n--- EARLIER CONVERSATION ---\n\n" . $transcript; + } +} diff --git a/inc/Engine/AI/conversation-loop.php b/inc/Engine/AI/conversation-loop.php index a1c1d617a..a5cf31108 100644 --- a/inc/Engine/AI/conversation-loop.php +++ b/inc/Engine/AI/conversation-loop.php @@ -79,6 +79,12 @@ function datamachine_run_conversation( datamachine_payload_without_runtime_objects( $payload ) ); + // Resolve optional per-agent conversation compaction. Default DISABLED: + // agents that have not opted into compaction get a no-op policy and no + // summarizer, so the substrate's maybe_compact() never fires. The summarizer + // closes over the cleaned loop payload so it can drive a tool-free model turn. + $conversation_compaction = datamachine_resolve_conversation_compaction( $modes, $loop_payload ); + // Build the turns budget through DM's registry (site-config-aware ceiling // resolution). The upstream loop owns increment + exceeded checks. $turn_budget = IterationBudgetRegistry::create( 'conversation_turns', 0, $max_turns ); @@ -236,32 +242,43 @@ function datamachine_run_conversation( ); // Run through the upstream substrate loop. + $loop_options = array( + 'max_turns' => $max_turns, + 'budgets' => array( $turn_budget ), + 'context' => array_merge( $loop_payload, array( + 'mode' => $mode, + 'modes' => $modes, + ) ), + 'request' => $conversation_request, + 'should_continue' => $should_continue, + 'transcript_persister' => $transcript_persister, + 'transcript_lock' => $transcript_lock, + 'transcript_session_id' => (string) ( $loop_payload['transcript_session_id'] ?? $loop_payload['session_id'] ?? '' ), + 'transcript_lock_ttl' => (int) ( $payload['transcript_lock_ttl'] ?? 300 ), + 'interrupt_source' => $interrupt_source, + 'on_event' => $on_event, + 'tool_executor' => $tool_executor, + 'tool_declarations' => $tools, + 'runtime_tool_store' => datamachine_runtime_tool_request_store(), + 'provider_turn_adapter' => $provider_turn_adapter, + 'pre_tool_mediator' => $pre_tool_mediator, + 'completion_policy' => $completion_policy, + ); + + // Attach conversation compaction only when the agent opted in. The substrate + // no-ops unless BOTH compaction_policy (array) and summarizer (callable) are + // present, so omitting them entirely keeps the non-compacting path identical + // to before for every agent that has not enabled compaction. + if ( $conversation_compaction['enabled'] ) { + $loop_options['compaction_policy'] = $conversation_compaction['policy']; + $loop_options['summarizer'] = $conversation_compaction['summarizer']; + } + try { $result = WP_Agent_Conversation_Loop::run( $messages, $provider_turn_adapter, - array( - 'max_turns' => $max_turns, - 'budgets' => array( $turn_budget ), - 'context' => array_merge( $loop_payload, array( - 'mode' => $mode, - 'modes' => $modes, - ) ), - 'request' => $conversation_request, - 'should_continue' => $should_continue, - 'transcript_persister' => $transcript_persister, - 'transcript_lock' => $transcript_lock, - 'transcript_session_id' => (string) ( $loop_payload['transcript_session_id'] ?? $loop_payload['session_id'] ?? '' ), - 'transcript_lock_ttl' => (int) ( $payload['transcript_lock_ttl'] ?? 300 ), - 'interrupt_source' => $interrupt_source, - 'on_event' => $on_event, - 'tool_executor' => $tool_executor, - 'tool_declarations' => $tools, - 'runtime_tool_store' => datamachine_runtime_tool_request_store(), - 'provider_turn_adapter' => $provider_turn_adapter, - 'pre_tool_mediator' => $pre_tool_mediator, - 'completion_policy' => $completion_policy, - ) + $loop_options ); } catch ( \RuntimeException $e ) { // The provider-turn adapter throws RuntimeException for wp-ai-client failures before @@ -298,12 +315,34 @@ function datamachine_run_conversation( try { $result = WP_Agent_Conversation_Result::normalize( $result ); $completion_policy_stopped = false; + $compaction_event_types = array( + \AgentsAPI\AI\WP_Agent_Conversation_Compaction::EVENT_STARTED, + \AgentsAPI\AI\WP_Agent_Conversation_Compaction::EVENT_COMPLETED, + \AgentsAPI\AI\WP_Agent_Conversation_Compaction::EVENT_FAILED, + \AgentsAPI\AI\WP_Agent_Conversation_Compaction::EVENT_ARCHIVED, + ); foreach ( is_array( $result['events'] ?? null ) ? $result['events'] : array() as $event ) { - if ( ! is_array( $event ) || ! in_array( (string) ( $event['type'] ?? '' ), array( DataMachineConversationStatus::COMPLETION_POLICY_STOP, DataMachineConversationStatus::COMPLETION_POLICY_CONTINUE ), true ) ) { + $event_type = is_array( $event ) ? (string) ( $event['type'] ?? '' ) : ''; + + // Surface substrate compaction lifecycle events through the DM event + // sink for observability, mirroring how request/tool events flow. + if ( in_array( $event_type, $compaction_event_types, true ) ) { + datamachine_emit_loop_event( + $event_sink, + $event_type, + array_merge( + $base_log_context, + is_array( $event['metadata'] ?? null ) ? $event['metadata'] : array() + ) + ); + continue; + } + + if ( ! is_array( $event ) || ! in_array( $event_type, array( DataMachineConversationStatus::COMPLETION_POLICY_STOP, DataMachineConversationStatus::COMPLETION_POLICY_CONTINUE ), true ) ) { continue; } - if ( DataMachineConversationStatus::COMPLETION_POLICY_STOP === (string) ( $event['type'] ?? '' ) ) { + if ( DataMachineConversationStatus::COMPLETION_POLICY_STOP === $event_type ) { $completion_policy_stopped = true; } @@ -2401,6 +2440,43 @@ function datamachine_resolve_completion_policy( array $modes, array $payload, ?D return new DefaultAgentConversationCompletionPolicy( $assertions ); } +/** + * Resolve optional per-agent conversation compaction for the chat/system loop. + * + * Reads the acting agent's compaction policy from agent_config (via the + * ConversationCompactionPolicyResolver) and, when enabled, builds the DM-owned + * summarizer callable the Agents API compaction contract consumes. Compaction is + * DISABLED by default: agents that have not opted in resolve to enabled=false, + * which keeps the substrate's maybe_compact() a strict no-op. + * + * @param array $modes Execution modes. + * @param array $loop_payload Cleaned loop payload (carries agent_id). + * @return array{enabled:bool,policy:array,summarizer:?callable} + */ +function datamachine_resolve_conversation_compaction( array $modes, array $loop_payload ): array { + $resolver = new \DataMachine\Engine\AI\Compaction\ConversationCompactionPolicyResolver(); + $policy = $resolver->resolve( + array( + 'agent_id' => (int) ( $loop_payload['agent_id'] ?? 0 ), + 'modes' => $modes, + ) + ); + + if ( empty( $policy['enabled'] ) ) { + return array( + 'enabled' => false, + 'policy' => $policy, + 'summarizer' => null, + ); + } + + return array( + 'enabled' => true, + 'policy' => $policy, + 'summarizer' => \DataMachine\Engine\AI\Compaction\ConversationCompactionSummarizer::build( $loop_payload, $policy ), + ); +} + /** * Resolve generic completion assertions from loop payload. * diff --git a/tests/conversation-compaction-policy-resolver-smoke.php b/tests/conversation-compaction-policy-resolver-smoke.php new file mode 100644 index 000000000..c7ba06950 --- /dev/null +++ b/tests/conversation-compaction-policy-resolver-smoke.php @@ -0,0 +1,153 @@ + '', + 'model' => '', + ); + } + } +} + +namespace DataMachine\Engine\AI { + // Stub the conversation runner referenced by the summarizer so requiring the + // summarizer file does not pull the full loop; the resolver smoke does not + // invoke the summarizer body. + if ( ! function_exists( 'DataMachine\\Engine\\AI\\datamachine_run_conversation' ) ) { + function datamachine_run_conversation( ...$args ): array { + return array( 'final_content' => '' ); + } + } +} + +namespace { + require_once __DIR__ . '/agents-api-loader.php'; + + if ( ! defined( 'ABSPATH' ) ) { + define( 'ABSPATH', __DIR__ . '/' ); + } + + if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $hook, $value, ...$args ) { + return $value; + } + } + if ( ! function_exists( 'wp_json_encode' ) ) { + function wp_json_encode( $data, $options = 0, $depth = 512 ) { + return json_encode( $data, $options, $depth ); + } + } + + datamachine_tests_require_agents_api(); + + require_once __DIR__ . '/../inc/Engine/AI/Compaction/ConversationCompactionPolicyResolver.php'; + require_once __DIR__ . '/../inc/Engine/AI/Compaction/ConversationCompactionSummarizer.php'; + + use DataMachine\Engine\AI\Compaction\ConversationCompactionPolicyResolver; + use DataMachine\Engine\AI\Compaction\ConversationCompactionSummarizer; + + $assertions = 0; + + function compaction_assert_same( $expected, $actual, string $message ): void { + global $assertions; + ++$assertions; + if ( $expected !== $actual ) { + fwrite( fopen( 'php://stderr', 'w' ), "FAIL: {$message}\nExpected: " . var_export( $expected, true ) . "\nActual: " . var_export( $actual, true ) . "\n" ); + exit( 1 ); + } + } + + $resolver = new ConversationCompactionPolicyResolver(); + + // 1. No agent / no opt-in => disabled no-op. + $policy = $resolver->resolve( array( 'agent_id' => 0, 'modes' => array( 'chat' ) ) ); + compaction_assert_same( false, $policy['enabled'], 'no agent resolves to disabled compaction' ); + compaction_assert_same( true, $policy['preserve_tool_boundaries'], 'disabled default still carries safe substrate defaults' ); + + // 2. Agent exists but has no compaction config => disabled no-op. + $GLOBALS['__compaction_agents'][1] = array( 'agent_config' => array() ); + $policy = $resolver->resolve( array( 'agent_id' => 1, 'modes' => array( 'chat' ) ) ); + compaction_assert_same( false, $policy['enabled'], 'agent without compaction config resolves to disabled' ); + + // 3. Explicit policy with enabled=true opts in. + $GLOBALS['__compaction_agents'][2] = array( + 'agent_config' => array( + 'conversation_compaction_policy' => array( + 'enabled' => true, + 'max_messages' => 30, + 'recent_messages' => 8, + ), + ), + ); + $policy = $resolver->resolve( array( 'agent_id' => 2, 'modes' => array( 'chat' ) ) ); + compaction_assert_same( true, $policy['enabled'], 'explicit enabled policy opts in' ); + compaction_assert_same( 30, $policy['max_messages'], 'explicit policy max_messages is honored' ); + compaction_assert_same( 8, $policy['recent_messages'], 'explicit policy recent_messages is honored' ); + + // 4. Capability flag alone opts in with substrate defaults. + $GLOBALS['__compaction_agents'][3] = array( + 'agent_config' => array( + 'supports_conversation_compaction' => true, + ), + ); + $policy = $resolver->resolve( array( 'agent_id' => 3, 'modes' => array( 'chat' ) ) ); + compaction_assert_same( true, $policy['enabled'], 'capability flag alone opts in' ); + compaction_assert_same( 40, $policy['max_messages'], 'capability-flag opt-in uses default max_messages' ); + + // 5. Explicit enabled=false wins even with the capability flag set. + $GLOBALS['__compaction_agents'][4] = array( + 'agent_config' => array( + 'supports_conversation_compaction' => true, + 'conversation_compaction_policy' => array( 'enabled' => false ), + ), + ); + $policy = $resolver->resolve( array( 'agent_id' => 4, 'modes' => array( 'chat' ) ) ); + compaction_assert_same( false, $policy['enabled'], 'explicit enabled=false overrides capability flag' ); + + // 6. Summarizer honors an explicit provider/model pin in the policy. + $pinned = ConversationCompactionSummarizer::resolveSummaryModel( + array( 'agent_id' => 7 ), + array( 'summary_provider' => 'openai', 'summary_model' => 'gpt-4o-mini' ) + ); + compaction_assert_same( 'openai', $pinned['provider'], 'summarizer honors pinned provider' ); + compaction_assert_same( 'gpt-4o-mini', $pinned['model'], 'summarizer honors pinned model' ); + + // 7. Summarizer falls back to the resolved system model when not pinned. + $GLOBALS['__compaction_system_model'] = array( 'provider' => 'anthropic', 'model' => 'claude-haiku' ); + $fallback = ConversationCompactionSummarizer::resolveSummaryModel( array( 'agent_id' => 7 ), array() ); + compaction_assert_same( 'anthropic', $fallback['provider'], 'summarizer falls back to system provider' ); + compaction_assert_same( 'claude-haiku', $fallback['model'], 'summarizer falls back to system model' ); + + // 8. build() returns a callable matching the contract arity. + $summarizer = ConversationCompactionSummarizer::build( array( 'agent_id' => 7 ), array( 'enabled' => true ) ); + compaction_assert_same( true, is_callable( $summarizer ), 'summarizer factory returns a callable' ); + + fwrite( fopen( 'php://stdout', 'w' ), "ConversationCompactionPolicyResolver smoke passed ({$assertions} assertions).\n" ); +}