Skip to content

Commit 108de80

Browse files
Dale Hurleyclaude
andcommitted
fix: prevent context compaction from corrupting tool_use/tool_result pairing
Compaction could fire between adding the assistant tool_use message and the user tool_result message, orphaning tool_use blocks and causing API validation errors on long-running multi-iteration agent workflows. Three fixes: - Defer auto-compaction when the last message is a dangling tool_use - Preserve the initial user task message during compaction - Rewrite compaction algorithm to correctly build preserved + recent message sets without misordering via array_unshift Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f50c168 commit 108de80

File tree

5 files changed

+473
-31
lines changed

5 files changed

+473
-31
lines changed

src/AgentContext.php

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,12 @@ public function addMessage(array $message): void
207207
{
208208
$this->messages[] = $message;
209209

210-
// Auto-compact if context manager is configured and threshold exceeded
211-
if ($this->contextManager !== null) {
210+
// Auto-compact if context manager is configured and threshold exceeded.
211+
// Skip compaction when the last message is an assistant message with
212+
// tool_use blocks but no following tool_result. Compacting at this point
213+
// would separate the tool_use from its tool_result (which hasn't been
214+
// added yet), corrupting the message structure required by the API.
215+
if ($this->contextManager !== null && ! $this->hasDanglingToolUse()) {
212216
$usage = $this->contextManager->getUsagePercentage(
213217
$this->messages,
214218
$this->getToolDefinitions()
@@ -223,6 +227,39 @@ public function addMessage(array $message): void
223227
}
224228
}
225229

230+
/**
231+
* Check if the last message is an assistant message with tool_use blocks
232+
* that doesn't have a following tool_result message.
233+
*
234+
* This indicates we're between adding the assistant response and the
235+
* tool results, and compaction must be deferred to avoid orphaning
236+
* the tool_use blocks.
237+
*/
238+
private function hasDanglingToolUse(): bool
239+
{
240+
if (empty($this->messages)) {
241+
return false;
242+
}
243+
244+
$lastMessage = $this->messages[count($this->messages) - 1];
245+
246+
if (($lastMessage['role'] ?? '') !== 'assistant') {
247+
return false;
248+
}
249+
250+
if (! is_array($lastMessage['content'] ?? null)) {
251+
return false;
252+
}
253+
254+
foreach ($lastMessage['content'] as $block) {
255+
if (is_array($block) && ($block['type'] ?? '') === 'tool_use') {
256+
return true;
257+
}
258+
}
259+
260+
return false;
261+
}
262+
226263
/**
227264
* Get current iteration count.
228265
*/

src/Context/ContextManager.php

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -81,31 +81,50 @@ public function compactMessages(array $messages, array $tools = []): array
8181
$messages = $this->clearToolResults($messages);
8282
}
8383

84-
// If still too large, remove oldest messages (keep first system message)
85-
$compacted = [];
86-
$systemMessage = null;
84+
// If clearing tool results was enough, return early
85+
if ($this->fitsInContext($messages, $tools)) {
86+
return $messages;
87+
}
88+
89+
// Extract preserved messages (system + initial user task) that must
90+
// always remain at the start of the compacted result.
91+
$preserved = [];
8792

88-
// Preserve system message if present
8993
if (! empty($messages) && ($messages[0]['role'] ?? '') === 'system') {
90-
$systemMessage = $messages[0];
91-
$compacted[] = $systemMessage;
94+
$preserved[] = $messages[0];
9295
$messages = array_slice($messages, 1);
9396
}
9497

95-
// Add newer message units (tool_use + tool_result pairs) in reverse order until we fit
98+
// Preserve the initial user message (the task prompt).
99+
// Without it, compacted messages may start with an assistant message,
100+
// violating the API requirement that messages begin with a user message.
101+
if (! empty($messages) && ($messages[0]['role'] ?? '') === 'user') {
102+
$preserved[] = $messages[0];
103+
$messages = array_slice($messages, 1);
104+
}
105+
106+
// Build message units from the remaining messages. Each unit is either
107+
// a tool_use+tool_result pair or a standalone message. We add units
108+
// from most recent to oldest, keeping as many as fit in the context.
96109
$units = $this->buildMessageUnits($messages);
97110
$recentUnits = array_reverse($units);
111+
112+
$recent = [];
98113
foreach ($recentUnits as $unit) {
99-
foreach (array_reverse($unit) as $message) {
100-
array_unshift($compacted, $message);
114+
$candidateRecent = array_merge($unit, $recent);
115+
$candidate = array_merge($preserved, $candidateRecent);
116+
117+
if ($this->fitsInContext($candidate, $tools)) {
118+
$recent = $candidateRecent;
119+
} else {
120+
// Adding this unit would exceed the context. Stop here.
121+
break;
101122
}
123+
}
102124

103-
if ($this->fitsInContext($compacted, $tools)) {
104-
$this->logger->debug('Compacted to ' . count($compacted) . ' messages');
125+
$compacted = array_merge($preserved, $recent);
105126

106-
return $compacted;
107-
}
108-
}
127+
$this->logger->debug('Compacted to ' . count($compacted) . ' messages');
109128

110129
return $compacted;
111130
}

tests/Unit/AgentContextTest.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,4 +298,67 @@ public function testToResultFailure(): void
298298
$this->assertEquals('Test error', $result->getError());
299299
$this->assertEquals(1, $result->getIterations());
300300
}
301+
302+
public function testAddMessageDefersCompactionForDanglingToolUse(): void
303+
{
304+
$contextManager = new \ClaudeAgents\Context\ContextManager(
305+
maxContextTokens: 50, // Very low to force compaction
306+
options: ['compact_threshold' => 0.1] // Low threshold to trigger compaction
307+
);
308+
309+
$context = new AgentContext(
310+
client: $this->mockClient,
311+
task: 'Analyze document',
312+
tools: [],
313+
config: $this->config,
314+
contextManager: $contextManager,
315+
);
316+
317+
// Add enough messages to exceed the context threshold
318+
for ($i = 0; $i < 5; $i++) {
319+
$context->addMessage([
320+
'role' => 'assistant',
321+
'content' => [
322+
['type' => 'tool_use', 'id' => "tool_{$i}", 'name' => 'read', 'input' => []],
323+
],
324+
]);
325+
$context->addMessage([
326+
'role' => 'user',
327+
'content' => [
328+
['type' => 'tool_result', 'tool_use_id' => "tool_{$i}", 'content' => str_repeat('x', 100)],
329+
],
330+
]);
331+
}
332+
333+
// Verify that every tool_use in the messages has a matching tool_result
334+
$messages = $context->getMessages();
335+
for ($i = 0; $i < count($messages); $i++) {
336+
$msg = $messages[$i];
337+
if (! is_array($msg['content'] ?? null)) {
338+
continue;
339+
}
340+
341+
$hasToolUse = false;
342+
foreach ($msg['content'] as $block) {
343+
if (is_array($block) && ($block['type'] ?? '') === 'tool_use') {
344+
$hasToolUse = true;
345+
break;
346+
}
347+
}
348+
349+
if (! $hasToolUse) {
350+
continue;
351+
}
352+
353+
// Next message must be a user message with tool_result
354+
$this->assertArrayHasKey($i + 1, $messages,
355+
"tool_use at message index {$i} must have a following message");
356+
$this->assertEquals('user', $messages[$i + 1]['role'],
357+
"Message after tool_use at {$i} must be user role");
358+
}
359+
360+
// Verify first message is still the user task
361+
$this->assertEquals('user', $messages[0]['role']);
362+
$this->assertEquals('Analyze document', $messages[0]['content']);
363+
}
301364
}

0 commit comments

Comments
 (0)