Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions inc/Engine/AI/Compaction/ConversationCompactionPolicyResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<?php
/**
* Conversation Compaction Policy Resolver.
*
* Resolves the per-turn conversation compaction policy for a live chat/system
* conversation from the acting agent's persisted `agent_config`. Sibling to
* ToolPolicyResolver (IF a tool is visible), MemoryPolicyResolver (WHICH memory
* files inject), ActionPolicyResolver (HOW a tool executes), and
* DirectivePolicyResolver (WHICH directives apply). Where those answer their
* respective questions, this answers "should the running transcript be compacted
* before each model dispatch, and with what policy?"
*
* The Agents API substrate (WP_Agent_Conversation_Compaction) owns the policy
* contract and the safe transcript surgery; this resolver only reads the
* agent's declarative opt-in and hands a normalized policy array to the loop.
*
* Backward compatibility: compaction is DISABLED by default. Agents that do not
* configure `conversation_compaction_policy` (and do not flip
* `supports_conversation_compaction`) resolve to a disabled policy, which makes
* the substrate's maybe_compact() a strict no-op.
*
* Supported agent_config shape:
*
* {
* "supports_conversation_compaction": true,
* "conversation_compaction_policy": {
* "enabled": true,
* "max_messages": 40,
* "recent_messages": 12,
* "summary_model": "...",
* "summary_provider": "...",
* ...
* }
* }
*
* @package DataMachine\Engine\AI\Compaction
*/

namespace DataMachine\Engine\AI\Compaction;

use AgentsAPI\AI\WP_Agent_Conversation_Compaction;
use DataMachine\Core\Database\Agents\Agents;

defined( 'ABSPATH' ) || exit;

class ConversationCompactionPolicyResolver {

/**
* Resolve the normalized compaction policy for a conversation turn.
*
* Always returns a normalized policy array (never null) so callers can pass
* it straight to the loop. When the agent has not opted in, the returned
* policy has `enabled => 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<string,mixed> $overrides Optional policy overrides merged last
* (for example a resolved summary model).
* }
* @return array<string,mixed> 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<string,mixed> $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<string,mixed>|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;
}
}
188 changes: 188 additions & 0 deletions inc/Engine/AI/Compaction/ConversationCompactionSummarizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
<?php
/**
* Conversation Compaction Summarizer.
*
* Supplies the summarizer callable consumed by the Agents API conversation
* compaction contract (WP_Agent_Conversation_Compaction). The substrate keeps
* provider/model execution OUT of itself by design — the compaction class is
* contract-only and asks the runtime for a summarizer. Data Machine provides
* that summarizer here.
*
* The summarizer drives a single, tool-free model turn through the same
* `datamachine_run_conversation()` entry point the rest of Data Machine uses,
* mirroring DailyMemoryTask's established "drive a summarization model call
* through Data Machine's own conversation primitive" pattern rather than
* inventing a parallel dispatch path. The summarization call passes NO
* compaction policy of its own, so it cannot recurse into compaction.
*
* Model selection: resolves the acting agent's `system`-mode model (the cheap
* maintenance model Data Machine already uses for memory compaction), with a
* `datamachine_conversation_compaction_summary_model` filter override.
*
* @package DataMachine\Engine\AI\Compaction
*/

namespace DataMachine\Engine\AI\Compaction;

use AgentsAPI\AI\WP_Agent_Message;
use DataMachine\Core\PluginSettings;
use function DataMachine\Engine\AI\datamachine_run_conversation;

defined( 'ABSPATH' ) || exit;

class ConversationCompactionSummarizer {

/**
* The agent mode used to resolve the maintenance summarization model.
*
* Compaction summaries are non-interactive maintenance work, so they reuse
* the same model class Data Machine resolves for system tasks (e.g. daily
* memory compaction).
*/
private const SUMMARY_MODE = 'system';

/**
* Resolve the provider/model pair used for compaction summaries.
*
* @param array<string,mixed> $loop_payload Cleaned loop payload (carries agent_id).
* @param array<string,mixed> $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<string,mixed> $loop_payload Cleaned loop payload.
* @param array<string,mixed> $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<string,mixed> $loop_payload Cleaned loop payload.
* @param array<string,mixed> $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<int,array<string,mixed>> $messages_to_summarize Earlier transcript slice.
* @param array<string,mixed> $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;
}
}
Loading
Loading