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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Agents API sits between tool/action discovery and product-specific automation. I
- Runtime message and result value objects.
- Agent package and package-artifact contracts.
- Agent memory store contracts and value objects.
- Conversation compaction policy and transcript transformation contracts.
- Conversation transcript store contracts.
- Runtime tool declaration value objects.

Expand Down Expand Up @@ -65,11 +66,40 @@ Register agent definitions from inside a `wp_agents_api_init` callback. Reads su
- `WP_Agents_Registry`
- `WP_Agent_Package*` value objects and artifact registry helpers
- `AgentsAPI\AI\AgentMessageEnvelope`
- `AgentsAPI\AI\AgentConversationCompaction`
- `AgentsAPI\AI\AgentConversationResult`
- `AgentsAPI\AI\Tools\RuntimeToolDeclaration`
- `AgentsAPI\Core\Database\Chat\ConversationTranscriptStoreInterface`
- `AgentsAPI\Core\FilesRepository\AgentMemoryStoreInterface` and memory value objects

## Conversation Compaction

Agents can declare support for runtime conversation compaction without tying Agents API to a provider or model executor:

```php
wp_register_agent(
'example-agent',
array(
'supports_conversation_compaction' => true,
'conversation_compaction_policy' => array(
'enabled' => true,
'max_messages' => 40,
'recent_messages' => 12,
'summary_provider' => 'example-provider',
'summary_model' => 'example-model',
),
)
);
```

`AgentsAPI\AI\AgentConversationCompaction::compact()` transforms a transcript before model dispatch. The caller supplies a summarizer callable, keeping low-level model execution outside Agents API. The result includes:

- `messages`: the transformed transcript, with a synthetic summary message followed by retained recent messages.
- `metadata.compaction`: status, compacted boundary, retained count, and summary metadata for persisted transcripts.
- `events`: `compaction_started`, `compaction_completed`, or `compaction_failed` lifecycle events that streaming clients can relay.

Boundary selection preserves tool-call/tool-result integrity by default. If summarization fails, the original normalized transcript is returned unchanged and a failure event is emitted rather than silently dropping history.

## Tests

```bash
Expand Down
1 change: 1 addition & 0 deletions agents-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
require_once AGENTS_API_PATH . 'src/Packages/register-agent-package-artifacts.php';
require_once AGENTS_API_PATH . 'src/Transcripts/ConversationTranscriptStoreInterface.php';
require_once AGENTS_API_PATH . 'src/Runtime/AgentMessageEnvelope.php';
require_once AGENTS_API_PATH . 'src/Runtime/AgentConversationCompaction.php';
require_once AGENTS_API_PATH . 'src/Runtime/AgentConversationResult.php';
require_once AGENTS_API_PATH . 'src/Tools/RuntimeToolDeclaration.php';
require_once AGENTS_API_PATH . 'src/Memory/AgentMemoryScope.php';
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"scripts": {
"test": [
"php tests/bootstrap-smoke.php",
"php tests/conversation-compaction-smoke.php",
"php tests/no-product-imports-smoke.php"
]
}
Expand Down
60 changes: 53 additions & 7 deletions src/Registry/class-wp-agent.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ class WP_Agent {
*/
protected array $default_config = array();

/**
* Whether this agent opts into runtime conversation compaction.
*
* @var bool
*/
protected bool $supports_conversation_compaction = false;

/**
* Declarative conversation compaction policy.
*
* @var array<string, mixed>
*/
protected array $conversation_compaction_policy = array();

/**
* Optional metadata.
*
Expand Down Expand Up @@ -130,6 +144,18 @@ protected function prepare_properties( array $args ): array {
$properties['default_config'] = $args['default_config'];
}

if ( isset( $args['supports_conversation_compaction'] ) ) {
$properties['supports_conversation_compaction'] = (bool) $args['supports_conversation_compaction'];
}

if ( isset( $args['conversation_compaction_policy'] ) ) {
if ( ! is_array( $args['conversation_compaction_policy'] ) ) {
throw new InvalidArgumentException( 'Agent conversation_compaction_policy property must be an array.' );
}

$properties['conversation_compaction_policy'] = \AgentsAPI\AI\AgentConversationCompaction::normalize_policy( $args['conversation_compaction_policy'] );
}

if ( isset( $args['meta'] ) ) {
if ( ! is_array( $args['meta'] ) ) {
throw new InvalidArgumentException( 'Agent meta property must be an array.' );
Expand Down Expand Up @@ -221,6 +247,24 @@ public function get_default_config(): array {
return $this->default_config;
}

/**
* Whether this agent opts into runtime conversation compaction.
*
* @return bool
*/
public function supports_conversation_compaction(): bool {
return $this->supports_conversation_compaction;
}

/**
* Retrieves declarative conversation compaction policy.
*
* @return array<string, mixed>
*/
public function get_conversation_compaction_policy(): array {
return $this->conversation_compaction_policy;
}

/**
* Retrieves optional metadata.
*
Expand All @@ -237,13 +281,15 @@ public function get_meta(): array {
*/
public function to_array(): array {
return array(
'slug' => $this->slug,
'label' => $this->label,
'description' => $this->description,
'memory_seeds' => $this->memory_seeds,
'owner_resolver' => $this->owner_resolver,
'default_config' => $this->default_config,
'meta' => $this->meta,
'slug' => $this->slug,
'label' => $this->label,
'description' => $this->description,
'memory_seeds' => $this->memory_seeds,
'owner_resolver' => $this->owner_resolver,
'default_config' => $this->default_config,
'supports_conversation_compaction' => $this->supports_conversation_compaction,
'conversation_compaction_policy' => $this->conversation_compaction_policy,
'meta' => $this->meta,
);
}

Expand Down
228 changes: 228 additions & 0 deletions src/Runtime/AgentConversationCompaction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
<?php
/**
* Agent conversation compaction contract.
*
* @package AgentsAPI
*/

namespace AgentsAPI\AI;

if ( ! defined( 'ABSPATH' ) ) {
exit;
}

/**
* Normalizes compaction policy and applies transcript compaction safely.
*
* This class defines the runtime contract only. Callers provide the summarizer
* callable, so provider/model execution stays outside Agents API.
*/
class AgentConversationCompaction {

public const EVENT_STARTED = 'compaction_started';
public const EVENT_COMPLETED = 'compaction_completed';
public const EVENT_FAILED = 'compaction_failed';

public const STATUS_SKIPPED = 'skipped';
public const STATUS_COMPACTED = 'compacted';
public const STATUS_FAILED = 'failed';

/**
* Default declarative compaction policy.
*
* @return array<string, mixed>
*/
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,
);
}

/**
* Normalize caller-provided compaction policy.
*
* @param array<string, mixed> $policy Raw policy.
* @return array<string, mixed>
*/
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'];

if ( $normalized['recent_messages'] >= $normalized['max_messages'] ) {
$normalized['recent_messages'] = max( 1, $normalized['max_messages'] - 1 );
}

return $normalized;
}

/**
* 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.
*
* @param array $messages Complete transcript messages.
* @param array $policy Compaction policy.
* @param callable $summarizer Summary producer supplied by the runtime.
* @return array{messages: array<int, array<string, mixed>>, metadata: array<string, mixed>, events: array<int, array<string, mixed>>}
*/
public static function compact( array $messages, array $policy, callable $summarizer ): array {
$policy = self::normalize_policy( $policy );
$normalized_messages = AgentMessageEnvelope::normalize_many( $messages );
$total_messages = count( $normalized_messages );

if ( ! $policy['enabled'] || $total_messages <= $policy['max_messages'] ) {
return self::result( $normalized_messages, self::STATUS_SKIPPED, array(), array() );
}

$cutoff = self::select_boundary( $normalized_messages, $policy );
if ( $cutoff <= 0 ) {
return self::result( $normalized_messages, self::STATUS_SKIPPED, array(), array() );
}

$summary_context = array(
'policy' => $policy,
'total_messages' => $total_messages,
'compact_count' => $cutoff,
'retained_count' => $total_messages - $cutoff,
'boundary' => array(
'compact_until' => $cutoff - 1,
'retain_from' => $cutoff,
),
);

$started_event = self::event( self::EVENT_STARTED, $summary_context );

try {
$summary = call_user_func( $summarizer, array_slice( $normalized_messages, 0, $cutoff ), $summary_context );
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['error'] = $error->getMessage();

return self::result(
$normalized_messages,
self::STATUS_FAILED,
$failure_context,
array( $started_event, self::event( self::EVENT_FAILED, $failure_context ) )
);
}

$summary_message = AgentMessageEnvelope::text(
$policy['summary_role'],
$policy['summary_prefix'] . "\n\n" . trim( $summary ),
array(
'agents_api_compaction' => array(
'compacted_message_count' => $cutoff,
'retained_message_count' => $total_messages - $cutoff,
'summary_provider' => $policy['summary_provider'],
'summary_model' => $policy['summary_model'],
),
)
);

$compacted_messages = array_merge( array( $summary_message ), array_slice( $normalized_messages, $cutoff ) );
$complete_context = $summary_context;
$complete_context['summary_message'] = $summary_message;

return self::result(
$compacted_messages,
self::STATUS_COMPACTED,
$complete_context,
array( $started_event, self::event( self::EVENT_COMPLETED, $complete_context ) )
);
}

/**
* Select the first retained message index without cutting tool boundaries.
*
* @param array<int, array<string, mixed>> $messages Normalized messages.
* @param array<string, mixed> $policy Normalized policy.
* @return int Boundary index.
*/
public static function select_boundary( array $messages, array $policy ): int {
$policy = self::normalize_policy( $policy );
$cutoff = max( 0, count( $messages ) - $policy['recent_messages'] );

if ( ! $policy['preserve_tool_boundaries'] ) {
return $cutoff;
}

while ( $cutoff > 0 && isset( $messages[ $cutoff ] ) && AgentMessageEnvelope::TYPE_TOOL_RESULT === AgentMessageEnvelope::type( $messages[ $cutoff ] ) ) {
--$cutoff;
}

while ( $cutoff > 0 && isset( $messages[ $cutoff - 1 ] ) && AgentMessageEnvelope::TYPE_TOOL_CALL === AgentMessageEnvelope::type( $messages[ $cutoff - 1 ] ) ) {
--$cutoff;
}

return $cutoff;
}

/**
* Build a normalized result array.
*
* @param array<int, array<string, mixed>> $messages Messages.
* @param string $status Compaction status.
* @param array<string, mixed> $metadata Compaction metadata.
* @param array<int, array<string, mixed>> $events Lifecycle events.
* @return array<string, mixed>
*/
private static function result( array $messages, string $status, array $metadata, array $events ): array {
$metadata['status'] = $status;

return array(
'messages' => $messages,
'metadata' => array( 'compaction' => $metadata ),
'events' => $events,
);
}

/**
* Build a lifecycle event payload for streaming clients.
*
* @param string $type Event type.
* @param array<string, mixed> $data Event data.
* @return array<string, mixed>
*/
private static function event( string $type, array $data ): array {
return array(
'type' => $type,
'metadata' => $data,
);
}

/**
* Normalize string policy fields.
*
* @param mixed $value Raw value.
* @param string $fallback Fallback value.
* @return string
*/
private static function normalize_string( $value, string $fallback ): string {
if ( ! is_string( $value ) ) {
return $fallback;
}

$value = trim( $value );
return '' === $value ? $fallback : $value;
}
}
1 change: 1 addition & 0 deletions tests/bootstrap-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

$namespace_map = array(
'DataMachine\\Engine\\AI\\AgentMessageEnvelope' => 'AgentsAPI\\AI\\AgentMessageEnvelope',
'DataMachine\\Engine\\AI\\AgentConversationCompaction' => 'AgentsAPI\\AI\\AgentConversationCompaction',
'DataMachine\\Engine\\AI\\AgentConversationResult' => 'AgentsAPI\\AI\\AgentConversationResult',
'DataMachine\\Engine\\AI\\Tools\\RuntimeToolDeclaration' => 'AgentsAPI\\AI\\Tools\\RuntimeToolDeclaration',
'DataMachine\\Core\\Database\\Chat\\ConversationTranscriptStoreInterface' => 'AgentsAPI\\Core\\Database\\Chat\\ConversationTranscriptStoreInterface',
Expand Down
Loading
Loading