Skip to content

Commit 5656177

Browse files
Merge feat_agent-exit-loop: TerminationException, DoneTool factory, Anthropic safety
2 parents 61fa764 + 68b1431 commit 5656177

File tree

8 files changed

+409
-65
lines changed

8 files changed

+409
-65
lines changed

src/Agent/AbstractAgent.php

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
use CarmeloSantana\PHPAgents\Contract\ToolExecutionPolicyInterface;
1111
use CarmeloSantana\PHPAgents\Contract\ToolInterface;
1212
use CarmeloSantana\PHPAgents\Contract\ToolkitInterface;
13-
use CarmeloSantana\PHPAgents\Enum\FinishReason;
1413
use CarmeloSantana\PHPAgents\Enum\ModelCapability;
1514
use CarmeloSantana\PHPAgents\Exception\ProviderException;
15+
use CarmeloSantana\PHPAgents\Exception\TerminationException;
1616
use CarmeloSantana\PHPAgents\Exception\ToolNotFoundException;
1717
use CarmeloSantana\PHPAgents\Message\AssistantMessage;
1818
use CarmeloSantana\PHPAgents\Message\Conversation;
@@ -95,7 +95,6 @@ public function run(MessageInterface $input, ?Conversation $history = null): Out
9595

9696
$allToolResults = [];
9797
$totalUsage = new Usage();
98-
$lastContent = '';
9998

10099
for ($i = 0; $i < $this->maxIterations(); $i++) {
101100
$this->notify('agent.iteration', $i + 1);
@@ -190,6 +189,20 @@ public function run(MessageInterface $input, ?Conversation $history = null): Out
190189
$tool = $this->findTool($toolCall->name, $allTools);
191190
$result = $tool->execute($toolCall->arguments);
192191
$result = $result->withCallId($toolCall->id);
192+
} catch (TerminationException $e) {
193+
// Tool requested immediate loop termination (e.g. restart)
194+
$result = ToolResult::success($e->getMessage())->withCallId($toolCall->id);
195+
$allToolResults[] = $result;
196+
$conversation->add(new ToolResultMessage($result));
197+
$this->notify('agent.tool_result', $result);
198+
199+
return new Output(
200+
content: $e->getMessage(),
201+
toolResults: $allToolResults,
202+
usage: $totalUsage,
203+
iterations: $i + 1,
204+
conversation: $conversation,
205+
);
193206
} catch (\Throwable $e) {
194207
$this->notify('agent.tool_error', $e->getMessage());
195208
$result = ToolResult::error($e->getMessage())->withCallId($toolCall->id);
@@ -203,18 +216,24 @@ public function run(MessageInterface $input, ?Conversation $history = null): Out
203216
continue;
204217
}
205218

206-
if ($response->content === $lastContent && $response->content !== '') {
207-
$conversation->add(new AssistantMessage(
208-
'Warning: You are repeating yourself. Please make progress or call the done tool.',
209-
));
219+
// Text-only response (no tool calls) — this IS the response.
220+
// The done tool is only needed after tool use to present results.
221+
if ($response->content !== '') {
222+
$conversation->add(new AssistantMessage($response->content));
223+
$this->notify('agent.done', ['response' => $response->content]);
224+
225+
return new Output(
226+
content: $response->content,
227+
toolResults: $allToolResults,
228+
usage: $totalUsage,
229+
model: $response->model,
230+
iterations: $i + 1,
231+
conversation: $conversation,
232+
);
210233
}
211234

212-
$lastContent = $response->content;
235+
// Empty response with no tool calls — let it retry
213236
$conversation->add(new AssistantMessage($response->content));
214-
215-
if ($response->finishReason === FinishReason::Stop && $response->content !== '') {
216-
continue;
217-
}
218237
}
219238

220239
$this->notify('agent.error', 'Max iterations reached');
@@ -239,7 +258,7 @@ private function allTools(): array
239258
$tools = [...$tools, ...$toolkit->tools()];
240259
}
241260

242-
$tools[] = new DoneTool();
261+
$tools[] = DoneTool::create();
243262

244263
return $tools;
245264
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CarmeloSantana\PHPAgents\Exception;
6+
7+
/**
8+
* Thrown by a tool to signal that the agent loop should terminate immediately.
9+
*
10+
* When a tool throws this exception, AbstractAgent catches it, records the
11+
* message as a successful tool result, and returns an Output without
12+
* continuing to the next iteration. This allows tools like "restart" or
13+
* "shutdown" to cleanly exit the agent loop while still persisting the
14+
* conversation state.
15+
*
16+
* The exception message is used as the tool result content and the final
17+
* Output content.
18+
*/
19+
final class TerminationException extends \RuntimeException {}

src/Message/Conversation.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,78 @@ public function repairToolPairing(): self
230230
return $result;
231231
}
232232

233+
/**
234+
* Merge consecutive messages with the same role into single messages.
235+
*
236+
* After pruning operations (dropOldestTurns, repairToolPairing), the
237+
* conversation may contain consecutive same-role messages. Some providers
238+
* (notably Anthropic) reject these. This method merges them by concatenating
239+
* text content with newlines.
240+
*
241+
* Tool messages are never merged — each tool result must correspond to
242+
* exactly one tool_use_id. Assistant messages with tool calls are also
243+
* kept separate to preserve their tool_call structure.
244+
*
245+
* Returns a new Conversation.
246+
*/
247+
public function mergeConsecutiveRoles(): self
248+
{
249+
$result = new self();
250+
$lastMsg = null;
251+
252+
foreach ($this->messages as $msg) {
253+
// Never merge tool result messages — they have unique tool_call_ids
254+
if ($msg->role() === Role::Tool) {
255+
$result->add($msg);
256+
$lastMsg = $msg;
257+
continue;
258+
}
259+
260+
// Never merge assistant messages with tool calls — structure matters
261+
if ($msg->role() === Role::Assistant && !empty($msg->toolCalls())) {
262+
$result->add($msg);
263+
$lastMsg = $msg;
264+
continue;
265+
}
266+
267+
// Merge consecutive same-role text-only messages
268+
if (
269+
$lastMsg !== null
270+
&& $lastMsg->role() === $msg->role()
271+
&& empty($lastMsg->toolCalls())
272+
) {
273+
// Remove the last message and replace with merged content
274+
$messages = $result->messages();
275+
$lastContent = is_string($lastMsg->content()) ? $lastMsg->content() : (json_encode($lastMsg->content()) ?: '');
276+
$newContent = is_string($msg->content()) ? $msg->content() : (json_encode($msg->content()) ?: '');
277+
$mergedContent = $lastContent . "\n\n" . $newContent;
278+
279+
// Rebuild the conversation without the last message
280+
$result = new self();
281+
for ($i = 0; $i < count($messages) - 1; $i++) {
282+
$result->add($messages[$i]);
283+
}
284+
285+
// Add merged message based on role.
286+
// At this point $msg is User, Assistant (text-only), or System
287+
// since Tool and Assistant-with-tool-calls are handled above.
288+
$merged = match ($msg->role()) {
289+
Role::User => new UserMessage($mergedContent),
290+
Role::Assistant => new AssistantMessage($mergedContent),
291+
default => new SystemMessage($mergedContent),
292+
};
293+
$result->add($merged);
294+
$lastMsg = $merged;
295+
continue;
296+
}
297+
298+
$result->add($msg);
299+
$lastMsg = $msg;
300+
}
301+
302+
return $result;
303+
}
304+
233305
/**
234306
* Progressively prune the conversation to fit within a token budget.
235307
*
@@ -269,6 +341,9 @@ public function fitWithinBudget(int $maxTokens, int $safetyMarginPercent = 20):
269341
// Step 4: Repair any orphaned tool results from dropped turns
270342
$result = $result->repairToolPairing();
271343

344+
// Step 5: Merge consecutive same-role messages that may result from pruning
345+
$result = $result->mergeConsecutiveRoles();
346+
272347
return $result;
273348
}
274349

src/Provider/AnthropicProvider.php

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,12 +163,15 @@ private function extractSystemAndMessages(array $messages): array
163163

164164
// Merge consecutive same-role messages (required by Anthropic).
165165
// Consecutive tool_result user messages must be combined into a single
166-
// user message with multiple content blocks.
166+
// user message with multiple content blocks. Handle mixed content types
167+
// (string + array) by normalizing strings to text content blocks.
167168
$merged = [];
168169
foreach ($formatted as $msg) {
169170
$last = end($merged);
170-
if ($last !== false && $last['role'] === $msg['role'] && is_array($last['content']) && is_array($msg['content'])) {
171-
$merged[array_key_last($merged)]['content'] = array_merge($last['content'], $msg['content']);
171+
if ($last !== false && $last['role'] === $msg['role']) {
172+
$lastContent = $this->normalizeContent($last['content']);
173+
$msgContent = $this->normalizeContent($msg['content']);
174+
$merged[array_key_last($merged)]['content'] = array_merge($lastContent, $msgContent);
172175
} else {
173176
$merged[] = $msg;
174177
}
@@ -302,4 +305,22 @@ private function parseStreamEvent(array $event): Response
302305
model: $this->model,
303306
);
304307
}
308+
309+
/**
310+
* Normalize message content to an array of content blocks.
311+
*
312+
* Anthropic requires content blocks when merging consecutive same-role
313+
* messages. Plain string content is converted to a text block.
314+
*
315+
* @param string|array<array<string, mixed>> $content
316+
* @return array<array<string, mixed>>
317+
*/
318+
private function normalizeContent(string|array $content): array
319+
{
320+
if (is_string($content)) {
321+
return $content !== '' ? [['type' => 'text', 'text' => $content]] : [];
322+
}
323+
324+
return $content;
325+
}
305326
}

src/Tool/DoneTool.php

Lines changed: 27 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,62 +5,39 @@
55
namespace CarmeloSantana\PHPAgents\Tool;
66

77
use CarmeloSantana\PHPAgents\Contract\ToolInterface;
8-
use CarmeloSantana\PHPAgents\Enum\ToolResultStatus;
98
use CarmeloSantana\PHPAgents\Tool\Parameter\StringParameter;
109

11-
final class DoneTool implements ToolInterface
10+
/**
11+
* Factory for the built-in "done" tool that signals agent completion.
12+
*
13+
* Returns a standard Tool instance using the same Parameter-driven schema
14+
* generation as all other tools — no hand-written toFunctionSchema().
15+
*/
16+
final class DoneTool
1217
{
1318
public const NAME = 'done';
1419

15-
public function name(): string
20+
/**
21+
* Create the done tool instance.
22+
*
23+
* Uses the generic Tool class with a StringParameter, ensuring
24+
* consistent schema generation across all tools.
25+
*/
26+
public static function create(): ToolInterface
1627
{
17-
return self::NAME;
18-
}
19-
20-
public function description(): string
21-
{
22-
return 'Call this tool when you have completed the task. '
23-
. 'Pass your final response in the "response" parameter. '
24-
. 'You MUST call this tool to finish — do not end without it.';
25-
}
26-
27-
public function parameters(): array
28-
{
29-
return [
30-
new StringParameter(
31-
name: 'response',
32-
description: 'Your final response to the user\'s request.',
33-
required: true,
34-
),
35-
];
36-
}
37-
38-
public function execute(array $input): ToolResult
39-
{
40-
return new ToolResult(
41-
ToolResultStatus::Success,
42-
$input['response'] ?? '',
43-
);
44-
}
45-
46-
public function toFunctionSchema(): array
47-
{
48-
return [
49-
'type' => 'function',
50-
'function' => [
51-
'name' => $this->name(),
52-
'description' => $this->description(),
53-
'parameters' => [
54-
'type' => 'object',
55-
'properties' => [
56-
'response' => [
57-
'type' => 'string',
58-
'description' => 'Your final response to the user\'s request.',
59-
],
60-
],
61-
'required' => ['response'],
62-
],
28+
return new Tool(
29+
name: self::NAME,
30+
description: 'Present your final response to the user after using tools. '
31+
. 'Pass your completed answer in the "response" parameter. '
32+
. 'Only needed after tool use — for simple conversation, respond with text directly.',
33+
parameters: [
34+
new StringParameter(
35+
name: 'response',
36+
description: 'Your final response to the user\'s request.',
37+
required: true,
38+
),
6339
],
64-
];
40+
callback: fn(array $input): ToolResult => ToolResult::success($input['response'] ?? ''),
41+
);
6542
}
6643
}

0 commit comments

Comments
 (0)