feat: AI chat tool-use (Claude function calling)

The Asistent AI chat can now query the tenant DB directly through Claude
tool use. AiToolExecutor exposes 5 read-only tools (search_clients,
get_vehicle, find_parts, recent_workorders, low_stock_parts) all scoped
to the current tenant via BelongsToTenant.

AiAssistantService::callClaude loops on stop_reason=tool_use up to 5 rounds:
- normalize message history to content blocks
- send `tools` definitions + messages to Anthropic API
- on tool_use: execute each tool, append tool_results as user turn, recall
- on end_turn: collect text + cumulative token counts + tool-call audit in
  AiMessage.meta.tools

Single-shot helpers (suggestDiagnosis, suggestPrice, vinRecommendations,
suggestParts) are unchanged — only the conversational chat gets tool-use.

Tests (3 new):
- two-round tool_use → execute → final text; verify 5 tools sent both rounds;
  cumulative tokens
- executor finds part by brand
- unknown tool name returns error blob

Full suite: 109 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 19:36:07 +00:00
parent 8fdfc9ef85
commit dfb92bf5e2
3 changed files with 359 additions and 25 deletions
+79 -25
View File
@@ -117,36 +117,90 @@ TXT;
protected function callClaude(string $key, AiChat $chat, string $msg, Company $company): array
{
$messages = $this->historyMessages($chat);
// Anthropic requires alternating user/assistant; system is separate.
$messages = array_values(array_filter($messages, fn ($m) => in_array($m['role'], ['user', 'assistant'], true)));
$r = Http::withHeaders([
'x-api-key' => $key,
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])
->timeout(60)
->post('https://api.anthropic.com/v1/messages', [
'model' => 'claude-sonnet-4-5',
'max_tokens' => 1024,
'system' => $this->buildSystemPrompt($company),
'messages' => $messages,
]);
// Normalize history to the structured content-block form (Claude tool-use
// requires content blocks for the assistant turn that emitted tool_use).
foreach ($messages as &$m) {
if (is_string($m['content'])) {
$m['content'] = [['type' => 'text', 'text' => $m['content']]];
}
}
unset($m);
if (! $r->successful()) {
return ['❌ ' . ($r->json('error.message') ?? 'Anthropic API error ' . $r->status()), ['status' => $r->status()]];
$headers = [
'x-api-key' => $key,
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
];
$system = $this->buildSystemPrompt($company);
$tools = AiToolExecutor::TOOLS;
$executor = app(AiToolExecutor::class);
$tokensIn = 0;
$tokensOut = 0;
$toolCalls = [];
$finalText = '';
$model = null;
// Loop on tool_use up to 5 rounds, then bail out with whatever text we have.
for ($round = 0; $round < 5; $round++) {
$r = Http::withHeaders($headers)->timeout(60)->post(
'https://api.anthropic.com/v1/messages',
[
'model' => 'claude-sonnet-4-5',
'max_tokens' => 1024,
'system' => $system,
'tools' => $tools,
'messages' => $messages,
]
);
if (! $r->successful()) {
return ['❌ ' . ($r->json('error.message') ?? 'Anthropic API error ' . $r->status()), ['status' => $r->status()]];
}
$body = $r->json();
$model = $body['model'] ?? $model;
$tokensIn += (int) ($body['usage']['input_tokens'] ?? 0);
$tokensOut += (int) ($body['usage']['output_tokens'] ?? 0);
$blocks = $body['content'] ?? [];
$finalText = collect($blocks)->where('type', 'text')->pluck('text')->implode("\n");
if (($body['stop_reason'] ?? null) !== 'tool_use') {
break;
}
// Append the assistant turn (with the tool_use block) to history.
$messages[] = ['role' => 'assistant', 'content' => $blocks];
// Execute every tool_use block and build the user reply with tool_results.
$toolResults = [];
foreach ($blocks as $b) {
if (($b['type'] ?? '') !== 'tool_use') continue;
$name = $b['name'];
$input = (array) ($b['input'] ?? []);
try {
$out = $executor->execute($name, $input);
} catch (\Throwable $e) {
$out = ['error' => $e->getMessage()];
}
$toolCalls[] = ['name' => $name, 'input' => $input];
$toolResults[] = [
'type' => 'tool_result',
'tool_use_id' => $b['id'],
'content' => json_encode($out, JSON_UNESCAPED_UNICODE),
];
}
$messages[] = ['role' => 'user', 'content' => $toolResults];
}
$body = $r->json();
$text = collect($body['content'] ?? [])
->where('type', 'text')
->pluck('text')
->implode("\n");
return [$text ?: '(răspuns gol)', [
'model' => $body['model'] ?? null,
'tokens_in' => $body['usage']['input_tokens'] ?? null,
'tokens_out' => $body['usage']['output_tokens'] ?? null,
return [$finalText ?: '(răspuns gol)', [
'model' => $model,
'tokens_in' => $tokensIn,
'tokens_out' => $tokensOut,
'tools' => $toolCalls,
]];
}