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
Requires
- adachsoft/ai-agent: ^0.8
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.89
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.4
- rector/rector: ^2.3
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
conversationContextStrategyIdandconversationContextStrategyParamsdepends on the version ofAgentConfigDto. 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
toolNamevalues for which no compaction is performed at all (both input and output remain untouched).
- List of
toolIdentifierFields(array<string, string[]>, default:[])- Map of
toolNameto 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.
- Map of
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
ConversationMessageDtoCollectionwith 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 withToolCallDto::$id),toolName,result(array<string, mixed>).
In many setups
role='tool'messages also havecontentset to ajson_encode($result)payload. The strategy ensures that once a tool result is compacted, the human-visiblecontentdoes not keep a large JSON payload.
Deduplication key (IdentifierGroupKey)
For each tool call, the strategy computes a group key:
- 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).
- it builds a deterministic string from
- If there is no configuration for the tool:
- the identifier key is a constant
"__tool_only__".
- the identifier key is a constant
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:
Builds an index of all tool calls:
$toolCallIndex[toolCallId] = [ 'toolName' => string, 'arguments' => array<string, mixed>, 'groupKey' => string, ];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 eachgroupKeyis left untouched and itsidis added toprotectedToolCallIds; - subsequent (older) results in the same group are compacted.
- the first (
- When processing tool calls:
- any call whose
idis inprotectedToolCallIdsis always left untouched; - otherwise, the first (
latest) call for eachgroupKeyis preserved and itsidis added toprotectedToolCallIds; - subsequent (older) calls in the same group are compacted.
- any call whose
Compaction behaviour
Compaction is delegated to PayloadCompactor, which operates on plain arrays
and uses PayloadSizer for size calculations.
PayloadSizer
string→strlen($value)null/bool/int/float→strlen((string) $value)array→strlen(json_encode($value))(withoutJSON_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(andsize > 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)), ], // ... ], ]
- replaces the value with the placeholder string
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):
- Assistant calls
read_file_contentwith{path: "A.php", position: 0, length: 6000}(call_1). - Tool returns a very large
contentforcall_1. - Assistant calls
read_file_contentagain with the same arguments (call_2). - Tool returns another large
contentforcall_2.
Result after transform:
call_2(latest group member) remains fully intact (input and output).call_1(older in the same group):result.contentis replaced with"[omitted]",- meta
_tool_compactionis 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:
- Assistant:
write_file_content(call_1) with{path: "A.php", content: "<VERY_LONG>"}. - Tool: small result
{status: "ok"}forcall_1. - Assistant:
write_file_content(call_2) with{path: "A.php", content: "<VERY_LONG_V2>"}. - Tool: small result
{status: "ok"}forcall_2.
Result:
call_2remains full (input and output).call_1(older in the same group):arguments.pathis preserved (identifier),arguments.contentis 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.