adachsoft/ai-agent-context-tool-compaction

Conversation context strategy for adachsoft/ai-agent that compacts old tool call payloads to reduce token usage.

Installs: 2

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Forks: 0

pkg:composer/adachsoft/ai-agent-context-tool-compaction

v0.2.0 2026-01-31 21:50 UTC

This package is not auto-updated.

Last update: 2026-02-01 09:38:57 UTC


README

Conversation context strategy for adachsoft/ai-agent that compacts old tool call payloads (arguments and results) in the conversation history to reduce LLM token usage.

  • PHP: >= 8.3
  • Runtime dependency: adachsoft/ai-agent (SPI: ConversationContextStrategyInterface)
  • Namespace: AdachSoft\\AiAgentContextToolCompaction\\

The strategy operates only on SPI DTOs from adachsoft/ai-agent and never interacts with the agent's internal domain model.

Installation

Install via Composer:

composer require adachsoft/ai-agent-context-tool-compaction

This package requires adachsoft/ai-agent (declared as a runtime dependency in composer.json).

Quick Start

1. Register the strategy in AiAgentBuilder

use AdachSoft\AiAgent\PublicApi\Builder\AiAgentBuilder;
use AdachSoft\AiAgentContextToolCompaction\ToolCompactionConversationContextStrategy;

$strategy = new ToolCompactionConversationContextStrategy();

$builder = (new AiAgentBuilder())
    // ... your ports and config ...
    ->withSpiConversationContextStrategy($strategy);

2. Enable the strategy in AgentConfigDto

The strategy is selected by its id on AgentConfigDto and configured via conversationContextStrategyParams.

use AdachSoft\AiAgent\PublicApi\Dto\AgentConfigDto;
use AdachSoft\AiAgentContextToolCompaction\ToolCompactionStrategyParams;

$config = new AgentConfigDto(
    // ... other required arguments ...
);

// Pseudocode: set these fields according to your AgentConfigDto API
$config->conversationContextStrategyId = ToolCompactionStrategyParams::STRATEGY_ID; // "tool_compaction"
$config->conversationContextStrategyParams = [
    'inputTrimBytes' => 100,
    'outputTrimBytes' => 100,
    'excludedTools' => [],
    'toolIdentifierFields' => [
        'read_file_content' => ['path', 'position', 'length'],
    ],
];

Note: the exact way of setting conversationContextStrategyId and conversationContextStrategyParams depends on the version of AgentConfigDto. The example above uses pseudo-code – adapt it to your public API.

3. Minimal configuration fixing the log bug

To ensure that consecutive read_file_content tool calls with the same path but different position/length are treated as different logical operations, configure:

$config->conversationContextStrategyParams = [
    'toolIdentifierFields' => [
        'read_file_content' => ['path', 'position', 'length'],
    ],
];

Without this configuration the strategy falls back to grouping only by toolName, which may compact older results for different segments of the same file.

Configuration

Parameters are passed as array<string, mixed> through AgentConfigDto::$conversationContextStrategyParams and resolved by ParamsResolver. Invalid types are handled fail-open – the strategy uses safe defaults and never throws.

Supported keys (all optional):

  • inputTrimBytes (int, default: 100)
    • Maximum payload size in bytes for individual tool argument fields before they are compacted.
  • outputTrimBytes (int, default: 100)
    • Maximum payload size in bytes for individual tool result fields before they are compacted.
  • excludedTools (string[], default: [])
    • List of toolName values for which no compaction is performed at all (both input and output remain untouched).
  • toolIdentifierFields (array<string, string[]>, default: [])
    • Map of toolName to the list of top-level argument keys that together act as a logical identifier.
    • These fields:
      • participate in deduplication key creation,
      • are never trimmed, even if they exceed the configured thresholds.

Identifier fallback rule

If a tool is not present in toolIdentifierFields, it is treated as if it had no identifier arguments. In this case, the deduplication key is based only on the toolName (see the algorithm section below). This may cause older calls of the same tool with different arguments to be compacted together.

Make sure to configure toolIdentifierFields for tools where arguments define "what" is being processed (e.g. path, position, length for read_file_content).

Algorithm Overview

The strategy implements the algorithm described in .tasks/task002-plan-v1.md:

  • Input: full SPI conversation history as a ConversationMessageDtoCollection.
  • Output: new ConversationMessageDtoCollection with the same messages in the same order, but with selected tool payloads compacted.
  • The strategy is a pure function over SPI DTOs:
    • it never mutates incoming DTO instances,
    • it never throws exceptions (fail-open behaviour).

Data model (SPI DTOs)

Relevant DTOs from adachsoft/ai-agent:

  • ConversationMessageDto:
    • role (system | user | assistant | tool),
    • content (?string),
    • toolCalls (ToolCallDtoCollection),
    • toolResults (ToolCallResultDtoCollection).
  • ToolCallDto:
    • id (tool call id),
    • toolName,
    • arguments (array<string, mixed>).
  • ToolCallResultDto:
    • id (tool call id – correlates with ToolCallDto::$id),
    • toolName,
    • result (array<string, mixed>).

In many setups role='tool' messages also have content set to a json_encode($result) payload. The strategy ensures that once a tool result is compacted, the human-visible content does not keep a large JSON payload.

Deduplication key (IdentifierGroupKey)

For each tool call, the strategy computes a group key:

  1. If toolIdentifierFields[toolName] is configured and non-empty:
    • it builds a deterministic string from (fieldName => value) pairs for the configured fields,
    • missing values are represented as null,
    • field order is preserved as in the configuration (no sorting).
  2. If there is no configuration for the tool:
    • the identifier key is a constant "__tool_only__".

The final IdentifierGroupKey is:

{toolName}|{identifierKey}

Protect the latest occurrence (input + output)

Business rule: for each IdentifierGroupKey, the latest occurrence of a logical tool call (input and output) must remain fully intact.

To achieve this, the strategy:

  1. Builds an index of all tool calls:

    $toolCallIndex[toolCallId] = [
        'toolName' => string,
        'arguments' => array<string, mixed>,
        'groupKey' => string,
    ];
    
  2. Iterates over messages from newest to oldest and keeps:

    • seenGroups: set<string> – which group keys were already seen from the end of the conversation,
    • protectedToolCallIds: set<string> – ids of tool calls that belong to the most recent occurrence of a group.

During this reverse scan:

  • When processing tool results:
    • the first (latest) result for each groupKey is left untouched and its id is added to protectedToolCallIds;
    • subsequent (older) results in the same group are compacted.
  • When processing tool calls:
    • any call whose id is in protectedToolCallIds is always left untouched;
    • otherwise, the first (latest) call for each groupKey is preserved and its id is added to protectedToolCallIds;
    • subsequent (older) calls in the same group are compacted.

Compaction behaviour

Compaction is delegated to PayloadCompactor, which operates on plain arrays and uses PayloadSizer for size calculations.

PayloadSizer

  • stringstrlen($value)
  • null / bool / int / floatstrlen((string) $value)
  • arraystrlen(json_encode($value)) (without JSON_THROW_ON_ERROR)
  • unsupported types (objects, resources, etc.) → size 0 (fail-open, no trimming)

compactInput(arguments, thresholdBytes, identifierFields)

  • Iterates over top-level keys in arguments.
  • If key is in identifierFields → the field is never trimmed.
  • Otherwise, if size(value) > thresholdBytes (and size > 0):

    • replaces the value with the placeholder string "[omitted]",
    • records metadata under the meta key '_tool_compaction':

      '_tool_compaction' => [
          'thresholdBytes' => $thresholdBytes,
          'omittedFields' => [
              $key => [
                  'bytes' => $calculatedSize,
                  'sha256' => hash('sha256', json_encode($originalValue)),
              ],
              // ...
          ],
      ]
      
  • Meta is added only if at least one field was actually omitted.

compactOutput(result, thresholdBytes)

  • Works analogously to compactInput() but without identifier fields – all keys are eligible for trimming.

Message content placeholder for tool results

If at least one tool result in a role='tool' message was compacted, the strategy also replaces the human-visible content with a short placeholder:

[tool_compaction] Tool result compacted for tool={toolName}, callId={toolCallId}. Large fields omitted.

The placeholder never includes any original tool data.

Fail-open and safety

  • Invalid parameter types → safe defaults (100/100/[]/[]).
  • JSON encoding failures → field left unchanged (no trimming).
  • The strategy never throws; if anything unexpected happens, the payload is left as-is.

Behaviour Examples

The following examples mirror the behaviour described in the original design specification.

Example A – read_file_content with identifiers (path, position, length)

Configuration:

[
    'inputTrimBytes' => 100,
    'outputTrimBytes' => 100,
    'toolIdentifierFields' => [
        'read_file_content' => ['path', 'position', 'length'],
    ],
]

Timeline (chronological):

  1. Assistant calls read_file_content with {path: "A.php", position: 0, length: 6000} (call_1).
  2. Tool returns a very large content for call_1.
  3. Assistant calls read_file_content again with the same arguments (call_2).
  4. Tool returns another large content for call_2.

Result after transform:

  • call_2 (latest group member) remains fully intact (input and output).
  • call_1 (older in the same group):

    • result.content is replaced with "[omitted]",
    • meta _tool_compaction is added to the result array,
    • the role='tool' message content becomes:

      [tool_compaction] Tool result compacted for tool=read_file_content, callId=call_1. Large fields omitted.
      

Example B – same path, different position/length (no cross-group dedup)

With the configuration from Example A, two calls:

  • call_A: {path: "A.php", position: 0, length: 6000}
  • call_B: {path: "A.php", position: 6000, length: 4000}

produce different identifier keys and thus different groups. Each (group, latest) pair is handled independently – no deduplication across (position, length).

Example C – write_file_content with large input, small output

Configuration:

[
    'inputTrimBytes' => 100,
    'outputTrimBytes' => 100,
    'toolIdentifierFields' => [
        'write_file_content' => ['path'],
    ],
]

Timeline:

  1. Assistant: write_file_content (call_1) with {path: "A.php", content: "<VERY_LONG>"}.
  2. Tool: small result {status: "ok"} for call_1.
  3. Assistant: write_file_content (call_2) with {path: "A.php", content: "<VERY_LONG_V2>"}.
  4. Tool: small result {status: "ok"} for call_2.

Result:

  • call_2 remains full (input and output).
  • call_1 (older in the same group):
    • arguments.path is preserved (identifier),
    • arguments.content is replaced with "[omitted]" + meta _tool_compaction.
    • Result is small enough → unchanged.

Example D – no toolIdentifierFields configuration

If a tool (e.g. list_files) is not present in toolIdentifierFields, then:

  • all its calls share the same identifier key "__tool_only__",
  • all occurrences of this tool across the conversation belong to a single deduplication group.

This means that older calls may be compacted even if their arguments differ. For such tools you should explicitly configure toolIdentifierFields.

License

MIT. See LICENSE file and composer.json.