Skip to content

Commit 61fa764

Browse files
Merge pull request #4 from carmelosantana/chore-fix-400
Enhance provider error reporting and Anthropic message handling
2 parents 5891cc2 + 7093f6c commit 61fa764

File tree

5 files changed

+145
-23
lines changed

5 files changed

+145
-23
lines changed

src/Agent/AbstractAgent.php

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use CarmeloSantana\PHPAgents\Tool\DoneTool;
2424
use CarmeloSantana\PHPAgents\Tool\ToolResult;
2525
use SplObserver;
26+
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
2627

2728
abstract class AbstractAgent implements AgentInterface
2829
{
@@ -105,10 +106,32 @@ public function run(MessageInterface $input, ?Conversation $history = null): Out
105106
$allTools,
106107
);
107108
} catch (\Throwable $e) {
108-
$this->notify('agent.error', $e->getMessage());
109+
$errorMessage = $e->getMessage();
110+
111+
// Extract API response body for HTTP client errors (4xx/5xx)
112+
// Symfony's ClientException only includes the status line, not the
113+
// API error body, for non-RFC-7807 APIs like Anthropic and OpenAI.
114+
if ($e instanceof ClientExceptionInterface) {
115+
try {
116+
$body = $e->getResponse()->getContent(false);
117+
$decoded = json_decode($body, true);
118+
119+
// Anthropic: {"type":"error","error":{"message":"..."}}
120+
// OpenAI: {"error":{"message":"..."}}
121+
$apiMessage = $decoded['error']['message']
122+
?? $decoded['message']
123+
?? $body;
124+
125+
$errorMessage .= "\n\nAPI response: " . $apiMessage;
126+
} catch (\Throwable) {
127+
// If we can't read the body, fall through with original message
128+
}
129+
}
130+
131+
$this->notify('agent.error', $errorMessage);
109132

110133
return new Output(
111-
content: 'Provider error: ' . $e->getMessage(),
134+
content: 'Provider error: ' . $errorMessage,
112135
toolResults: $allToolResults,
113136
usage: $totalUsage,
114137
iterations: $i + 1,

src/Provider/AnthropicProvider.php

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@ final class AnthropicProvider extends AbstractProvider
1818

1919
public function __construct(
2020
string $model = 'claude-sonnet-4-20250514',
21+
string $baseUrl = 'https://api.anthropic.com/v1',
2122
string $apiKey = '',
2223
?HttpClientInterface $httpClient = null,
2324
) {
2425
parent::__construct(
2526
model: $model,
26-
baseUrl: 'https://api.anthropic.com/v1',
27+
baseUrl: $baseUrl,
2728
apiKey: $apiKey,
2829
httpClient: $httpClient,
2930
);
@@ -148,7 +149,7 @@ protected function formatMessages(array $messages): array
148149
private function extractSystemAndMessages(array $messages): array
149150
{
150151
$systemPrompt = '';
151-
$formattedMessages = [];
152+
$formatted = [];
152153

153154
foreach ($messages as $message) {
154155
if ($message->role() === Role::System) {
@@ -157,10 +158,23 @@ private function extractSystemAndMessages(array $messages): array
157158
continue;
158159
}
159160

160-
$formattedMessages[] = $this->formatAnthropicMessage($message);
161+
$formatted[] = $this->formatAnthropicMessage($message);
161162
}
162163

163-
return [$systemPrompt, $formattedMessages];
164+
// Merge consecutive same-role messages (required by Anthropic).
165+
// Consecutive tool_result user messages must be combined into a single
166+
// user message with multiple content blocks.
167+
$merged = [];
168+
foreach ($formatted as $msg) {
169+
$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']);
172+
} else {
173+
$merged[] = $msg;
174+
}
175+
}
176+
177+
return [$systemPrompt, $merged];
164178
}
165179

166180
/**
@@ -175,19 +189,50 @@ private function formatAnthropicMessage(MessageInterface $message): array
175189
default => 'user',
176190
};
177191

192+
// Tool result messages → user message with tool_result content block
178193
if ($message->role() === Role::Tool) {
194+
$toolCallId = $message->toolCallId();
195+
196+
// Anthropic requires a non-null tool_use_id. Generate a fallback
197+
// for replayed conversations where the ID was not persisted.
198+
if ($toolCallId === null || $toolCallId === '') {
199+
$toolCallId = 'toolu_' . bin2hex(random_bytes(12));
200+
}
201+
179202
return [
180203
'role' => 'user',
181204
'content' => [
182205
[
183206
'type' => 'tool_result',
184-
'tool_use_id' => $message->toolCallId(),
207+
'tool_use_id' => $toolCallId,
185208
'content' => $message->content(),
186209
],
187210
],
188211
];
189212
}
190213

214+
// Assistant messages with tool calls → content blocks with tool_use
215+
if ($message->role() === Role::Assistant && !empty($message->toolCalls())) {
216+
$content = [];
217+
$text = $message->content();
218+
if (is_string($text) && $text !== '') {
219+
$content[] = ['type' => 'text', 'text' => $text];
220+
}
221+
foreach ($message->toolCalls() as $toolCall) {
222+
$content[] = [
223+
'type' => 'tool_use',
224+
'id' => $toolCall->id,
225+
'name' => $toolCall->name,
226+
'input' => !empty($toolCall->arguments) ? $toolCall->arguments : (object) [],
227+
];
228+
}
229+
230+
return [
231+
'role' => 'assistant',
232+
'content' => $content,
233+
];
234+
}
235+
191236
return [
192237
'role' => $role,
193238
'content' => $message->content(),

src/Provider/ProviderFactory.php

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,41 @@
99

1010
final class ProviderFactory
1111
{
12+
/**
13+
* Conventional environment variable names for provider API keys.
14+
*
15+
* Checked via getenv() before falling back to openclaw.json config values.
16+
* Coqui's CredentialResolver calls putenv() at boot, so workspace .env
17+
* entries are automatically available here.
18+
*/
19+
private const ENV_KEY_MAP = [
20+
'openai' => 'OPENAI_API_KEY',
21+
'anthropic' => 'ANTHROPIC_API_KEY',
22+
'openrouter' => 'OPENROUTER_API_KEY',
23+
];
24+
25+
public function __construct(
26+
private readonly ?ConfigInterface $config = null,
27+
) {}
28+
29+
/**
30+
* Create a provider from a model string using injected config.
31+
*
32+
* Preferred over the static method when you have a factory instance,
33+
* since the config is already bound and doesn't need to be passed each time.
34+
*/
35+
public function create(string $modelString): ProviderInterface
36+
{
37+
return self::fromModelString($modelString, $this->config);
38+
}
39+
1240
/**
1341
* Create a provider from an OpenClaw-style model string.
1442
*
1543
* @param string $modelString e.g., "ollama/llama3.2:latest"
1644
* @param ConfigInterface|null $config OpenClaw config for baseUrl/apiKey lookups
1745
*/
46+
1847
public static function fromModelString(
1948
string $modelString,
2049
?ConfigInterface $config = null,
@@ -23,7 +52,7 @@ public static function fromModelString(
2352

2453
$providerConfig = $config?->getProviderConfig($providerName) ?? [];
2554
$baseUrl = self::resolveBaseUrl($providerName, $providerConfig);
26-
$apiKey = $providerConfig['apiKey'] ?? '';
55+
$apiKey = self::resolveApiKey($providerName, $providerConfig);
2756

2857
return match ($providerName) {
2958
'ollama' => new OllamaProvider(
@@ -32,12 +61,13 @@ public static function fromModelString(
3261
),
3362
'anthropic' => new AnthropicProvider(
3463
model: $model,
35-
apiKey: is_string($apiKey) ? $apiKey : '',
64+
baseUrl: $baseUrl,
65+
apiKey: $apiKey,
3666
),
3767
default => new OpenAICompatibleProvider(
3868
model: $model,
3969
baseUrl: $baseUrl,
40-
apiKey: is_string($apiKey) ? $apiKey : '',
70+
apiKey: $apiKey,
4171
),
4272
};
4373
}
@@ -92,4 +122,28 @@ private static function defaultBaseUrl(string $provider): string
92122
default => '',
93123
};
94124
}
125+
126+
/**
127+
* Resolve API key with environment variable override.
128+
*
129+
* Priority: getenv(PROVIDER_API_KEY) > config apiKey > empty string.
130+
* This allows .env files to override hardcoded config values, and
131+
* enables Coqui's CredentialTool to manage provider keys at runtime.
132+
*
133+
* @param array<string, mixed> $providerConfig
134+
*/
135+
private static function resolveApiKey(string $provider, array $providerConfig): string
136+
{
137+
// Check environment variable first (highest priority)
138+
$envVar = self::ENV_KEY_MAP[$provider] ?? strtoupper($provider) . '_API_KEY';
139+
$envValue = getenv($envVar);
140+
if ($envValue !== false && $envValue !== '') {
141+
return $envValue;
142+
}
143+
144+
// Fall back to config value
145+
$configKey = $providerConfig['apiKey'] ?? '';
146+
147+
return is_string($configKey) ? $configKey : '';
148+
}
95149
}

src/Tool/Tool.php

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ public function __construct(
1818
private readonly string $description,
1919
private readonly array $parameters,
2020
private readonly Closure $callback,
21-
private readonly int $maxTries = 3,
2221
) {}
2322

2423
public function name(): string
@@ -36,11 +35,6 @@ public function parameters(): array
3635
return $this->parameters;
3736
}
3837

39-
public function maxTries(): int
40-
{
41-
return $this->maxTries;
42-
}
43-
4438
public function execute(array $input): ToolResult
4539
{
4640
try {
@@ -69,16 +63,21 @@ public function toFunctionSchema(): array
6963
}
7064
}
7165

66+
$schema = [
67+
'type' => 'object',
68+
'properties' => empty($properties) ? new \stdClass() : $properties,
69+
];
70+
71+
if (!empty($required)) {
72+
$schema['required'] = $required;
73+
}
74+
7275
return [
7376
'type' => 'function',
7477
'function' => [
7578
'name' => $this->name,
7679
'description' => $this->description,
77-
'parameters' => [
78-
'type' => 'object',
79-
'properties' => $properties,
80-
'required' => $required,
81-
],
80+
'parameters' => $schema,
8281
],
8382
];
8483
}

tests/Unit/Tool/ToolTest.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,14 @@
8282
expect($schema['function']['parameters']['required'])->toBe(['query']);
8383
});
8484

85-
test('tool default maxTries is 3', function () {
85+
test('toFunctionSchema omits required when no required parameters', function () {
8686
$tool = new Tool(
8787
name: 'test',
8888
description: 'test',
8989
parameters: [],
9090
callback: fn(array $input) => '',
9191
);
9292

93-
expect($tool->maxTries())->toBe(3);
93+
$schema = $tool->toFunctionSchema();
94+
expect($schema['function']['parameters'])->not->toHaveKey('required');
9495
});

0 commit comments

Comments
 (0)