<?php

declare(strict_types=1);

namespace NeuronAI\Chat\History;

use NeuronAI\Chat\Messages\ToolCallMessage;
use NeuronAI\Chat\Messages\ToolCallResultMessage;
use NeuronAI\Tools\ToolInterface;

class TokenCounter implements TokenCounterInterface
{
    public function __construct(
        protected float $charsPerToken = 4.0,
        protected float $extraTokensPerMessage = 3.0
    ) {
    }

public function count(array $messages): int
    {
        $tokenCount = 0.0;
        foreach ($messages as $message) {
            $messageChars = 0;
            $content = $message->getContent();
            if (\is_string($content)) {
                $messageChars += \strlen($content);
            } elseif ($content !== null) {
                $messageChars += \strlen(\json_encode($content));
            }
            if ($message instanceof ToolCallMessage && !\is_array($content)) {
                $tools = $message->getTools();
                if ($tools !== []) {
                    $toolsContent = \json_encode(\array_map(fn (ToolInterface $tool): array => $tool->jsonSerialize(), $tools));
                    $messageChars += \strlen($toolsContent);
                }
            }
            if ($message instanceof ToolCallResultMessage) {
                $tools = $message->getTools();
                foreach ($tools as $tool) {
                    $serialized = $tool->jsonSerialize();
                    if (isset($serialized['id'])) {
                        $messageChars += \strlen($serialized['id']);
                    }
                }
            }
            $messageChars += \strlen($message->getRole());
            $tokenCount += \ceil($messageChars / $this->charsPerToken);
            $tokenCount += $this->extraTokensPerMessage;
        }
        return (int) \ceil($tokenCount);
    }

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\History;

use NeuronAI\Chat\Attachments\Document;
use NeuronAI\Chat\Attachments\Image;
use NeuronAI\Chat\Enums\AttachmentContentType;
use NeuronAI\Chat\Enums\AttachmentType;
use NeuronAI\Chat\Enums\MessageRole;
use NeuronAI\Chat\Messages\AssistantMessage;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\ToolCallMessage;
use NeuronAI\Chat\Messages\ToolCallResultMessage;
use NeuronAI\Chat\Messages\Usage;
use NeuronAI\Chat\Messages\UserMessage;
use NeuronAI\Tools\Tool;

abstract class AbstractChatHistory implements ChatHistoryInterface
{
    protected array $history = [];

    public function __construct(
        protected int $contextWindow = 50000,
        protected TokenCounterInterface $tokenCounter = new TokenCounter()
    ) {
    }

    abstract public function setMessages(array $messages): ChatHistoryInterface;

    abstract protected function clear(): ChatHistoryInterface;

    public function addMessage(Message $message): ChatHistoryInterface
    {
        $this->history[] = $message;

        $this->trimHistory();

        $this->setMessages($this->history);

        return $this;
    }

    public function getMessages(): array
    {
        return $this->history;
    }

    public function getLastMessage(): Message|false
    {
        return \end($this->history);
    }

    public function flushAll(): ChatHistoryInterface
    {
        $this->clear();
        $this->history = [];
        return $this;
    }

    public function calculateTotalUsage(): int
    {
        return $this->tokenCounter->count($this->history);
    }

    protected function trimHistory(): void
    {
        if ($this->history === []) {
            return;
        }

        $tokenCount = $this->tokenCounter->count($this->history);

        // Early exit if all messages fit within the token limit
        if ($tokenCount <= $this->contextWindow) {
            $this->ensureValidMessageSequence();
            return;
        }

        // Binary search to find how many messages to skip from the beginning
        $skipFrom = $this->findMaxFittingMessages();

        $this->history = \array_slice($this->history, $skipFrom);

        // Ensure valid message sequence
        $this->ensureValidMessageSequence();
    }

    /**
     * Binary search to find the maximum number of messages that fit within the token limit.
     *
     * @return int The index of the first element to retain (keeping most recent messages) - 0 Skip no messages (include all) - count($this->history): Skip all messages (include none)
     */
    private function findMaxFittingMessages(): int
    {
        $totalMessages = \count($this->history);
        $left = 0;
        $right = $totalMessages;

        while ($left < $right) {
            $mid = \intval(($left + $right) / 2);
            $subset = \array_slice($this->history, $mid);

            if ($this->tokenCounter->count($subset) <= $this->contextWindow) {
                // Fits! Try including more messages (skip fewer)
                $right = $mid;
            } else {
                // Doesn't fit! Need to skip more messages
                $left = $mid + 1;
            }
        }

        return $left;
    }

    /**
     * Ensures the message list:
     * 1. Starts with a UserMessage
     * 2. Ends with an AssistantMessage
     * 3. Maintains tool call/result pairs
     */
    protected function ensureValidMessageSequence(): void
    {
        // Ensure it starts with a UserMessage
        $this->ensureStartsWithUser();

        // Ensure it ends with an AssistantMessage
        $this->ensureValidAlternation();
    }

    /**
     * Ensures the message list starts with a UserMessage.
     */
    protected function ensureStartsWithUser(): void
    {
        // Find the first UserMessage
        $firstUserIndex = null;
        foreach ($this->history as $index => $message) {
            if ($message->getRole() === MessageRole::USER->value) {
                $firstUserIndex = $index;
                break;
            }
        }

        if ($firstUserIndex === null) {
            // No UserMessage found
            $this->history = [];
            return;
        }

        if ($firstUserIndex === 0) {
            return;
        }

        if ($firstUserIndex > 0) {
            // Remove messages before the first user message
            $this->history = \array_slice($this->history, $firstUserIndex);
        }
    }

    /**
     * Ensures valid alternation between user and assistant messages.
     */
    protected function ensureValidAlternation(): void
    {
        $result = [];
        $expectingRole = [MessageRole::USER->value]; // Should start with user

        foreach ($this->history as $message) {
            $messageRole = $message->getRole();

            // Tool result messages have a special case - they're user messages
            // but can only follow tool call messages (assistant)
            // This is valid after a ToolCallMessage
            if ($message instanceof ToolCallResultMessage && ($result !== [] && $result[\count($result) - 1] instanceof ToolCallMessage)) {
                $result[] = $message;
                // After the tool result, we expect assistant again
                $expectingRole = [MessageRole::ASSISTANT->value, MessageRole::MODEL->value];
                continue;
            }

            // Check if this message has the expected role
            if (\in_array($messageRole, $expectingRole, true)) {
                $result[] = $message;
                // Toggle the expected role
                $expectingRole = ($expectingRole === [MessageRole::USER->value])
                    ? [MessageRole::ASSISTANT->value, MessageRole::MODEL->value]
                    : [MessageRole::USER->value];
            }
            // If not the expected role, we have an invalid alternation
            // Skip this message to maintain a valid sequence
        }

        $this->history = $result;
    }

    public function jsonSerialize(): array
    {
        return $this->getMessages();
    }

    protected function deserializeMessages(array $messages): array
    {
        return \array_map(fn (array $message): Message => match ($message['type'] ?? null) {
            'tool_call' => $this->deserializeToolCall($message),
            'tool_call_result' => $this->deserializeToolCallResult($message),
            default => $this->deserializeMessage($message),
        }, $messages);
    }

    protected function deserializeMessage(array $message): Message
    {
        $messageRole = MessageRole::from($message['role']);
        $messageContent = $message['content'] ?? null;

        $item = match ($messageRole) {
            MessageRole::ASSISTANT => new AssistantMessage($messageContent),
            MessageRole::USER => new UserMessage($messageContent),
            default => new Message($messageRole, $messageContent)
        };

        $this->deserializeMeta($message, $item);

        return $item;
    }

    protected function deserializeToolCall(array $message): ToolCallMessage
    {
        $tools = \array_map(fn (array $tool) => Tool::make($tool['name'], $tool['description'])
            ->setInputs($tool['inputs'])
            ->setCallId($tool['callId'] ?? null), $message['tools']);

        $item = new ToolCallMessage($message['content'], $tools);

        $this->deserializeMeta($message, $item);

        return $item;
    }

    protected function deserializeToolCallResult(array $message): ToolCallResultMessage
    {
        $tools = \array_map(fn (array $tool) => Tool::make($tool['name'], $tool['description'])
            ->setInputs($tool['inputs'])
            ->setCallId($tool['callId'])
            ->setResult($tool['result']), $message['tools']);

        return new ToolCallResultMessage($tools);
    }

    protected function deserializeMeta(array $message, Message $item): void
    {
        foreach ($message as $key => $value) {
            if ($key === 'role') {
                continue;
            }
            if ($key === 'content') {
                continue;
            }
            if ($key === 'usage') {
                $item->setUsage(
                    new Usage($message['usage']['input_tokens'], $message['usage']['output_tokens'])
                );
                continue;
            }
            if ($key === 'attachments') {
                foreach ($message['attachments'] as $attachment) {
                    switch (AttachmentType::from($attachment['type'])) {
                        case AttachmentType::IMAGE:
                            $item->addAttachment(
                                new Image(
                                    $attachment['content'],
                                    AttachmentContentType::from($attachment['content_type']),
                                    $attachment['media_type'] ?? null
                                )
                            );
                            break;
                        case AttachmentType::DOCUMENT:
                            $item->addAttachment(
                                new Document(
                                    $attachment['content'],
                                    AttachmentContentType::from($attachment['content_type']),
                                    $attachment['media_type'] ?? null
                                )
                            );
                            break;
                    }

                }
                continue;
            }
            $item->addMetadata($key, $value);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI;

use NeuronAI\Chat\Messages\AssistantMessage;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\ToolCallMessage;
use NeuronAI\Chat\Messages\Usage;
use NeuronAI\Observability\Events\AgentError;

trait HandleStream
{
    public function stream(Message|array $messages): \Generator
    {
        try {
            $this->notify('stream-start');

            $this->fillChatHistory($messages);

            $tools = $this->bootstrapTools();

            $stream = $this->resolveProvider()
                ->systemPrompt($this->resolveInstructions())
                ->setTools($tools)
                ->stream(
                    $this->resolveChatHistory()->getMessages(),
                    function (ToolCallMessage $toolCallMessage) {
                        $toolCallResult = $this->executeTools($toolCallMessage);
                        yield from self::stream([$toolCallMessage, $toolCallResult]);
                    }
                );

            $content = '';
            $usage = new Usage(0, 0);
            foreach ($stream as $text) {
                // Catch usage when streaming
                $decoded = \json_decode((string) $text, true);
                if (\is_array($decoded) && \array_key_exists('usage', $decoded)) {
                    $usage->inputTokens += $decoded['usage']['input_tokens'] ?? 0;
                    $usage->outputTokens += $decoded['usage']['output_tokens'] ?? 0;
                    continue;
                }

                $content .= $text;
                yield $text;
            }

            $response = new AssistantMessage($content);
            $response->setUsage($usage);

            // Avoid double saving due to the recursive call.
            $last = $this->resolveChatHistory()->getLastMessage();
            if ($response->getRole() !== $last->getRole()) {
                $this->fillChatHistory($response);
            }

            $this->notify('stream-stop');
        } catch (\Throwable $exception) {
            $this->notify('error', new AgentError($exception));
            throw $exception;
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Anthropic;

use NeuronAI\Chat\Attachments\Attachment;
use NeuronAI\Chat\Enums\AttachmentContentType;
use NeuronAI\Chat\Enums\MessageRole;
use NeuronAI\Chat\Messages\AssistantMessage;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\ToolCallMessage;
use NeuronAI\Chat\Messages\ToolCallResultMessage;
use NeuronAI\Chat\Messages\UserMessage;
use NeuronAI\Exceptions\ProviderException;
use NeuronAI\Providers\MessageMapperInterface;
use NeuronAI\Tools\ToolInterface;

class MessageMapper implements MessageMapperInterface
{
    protected array $mapping = [];

    public function map(array $messages): array
    {
        $this->mapping = [];

        foreach ($messages as $message) {
            match ($message::class) {
                Message::class,
                UserMessage::class,
                AssistantMessage::class => $this->mapMessage($message),
                ToolCallMessage::class => $this->mapToolCall($message),
                ToolCallResultMessage::class => $this->mapToolsResult($message),
                default => throw new ProviderException('Could not map message type '.$message::class),
            };
        }

        return $this->mapping;
    }

    protected function mapMessage(Message $message): void
    {
        $payload = $message->jsonSerialize();

        if (\array_key_exists('usage', $payload)) {
            unset($payload['usage']);
        }

        $attachments = $message->getAttachments();

        if (\is_string($payload['content']) && $attachments) {
            $payload['content'] = [
                [
                    'type' => 'text',
                    'text' => $payload['content'],
                ],
            ];
        }

        foreach ($attachments as $attachment) {
            $payload['content'][] = $this->mapAttachment($attachment);
        }

        unset($payload['attachments']);

        $this->mapping[] = $payload;
    }

    protected function mapAttachment(Attachment $attachment): array
    {
        return match($attachment->contentType) {
            AttachmentContentType::URL => [
                'type' => $attachment->type->value,
                'source' => [
                    'type' => 'url',
                    'url' => $attachment->content,
                ],
            ],
            AttachmentContentType::BASE64 => [
                'type' => $attachment->type->value,
                'source' => [
                    'type' => 'base64',
                    'media_type' => $attachment->mediaType,
                    'data' => $attachment->content,
                ],
            ],
        };
    }

    protected function mapToolCall(ToolCallMessage $message): void
    {
        $message = $message->jsonSerialize();

        if (\array_key_exists('usage', $message)) {
            unset($message['usage']);
        }

        unset($message['type']);
        unset($message['tools']);

        $this->mapping[] = $message;
    }

    protected function mapToolsResult(ToolCallResultMessage $message): void
    {
        $this->mapping[] = [
            'role' => MessageRole::USER,
            'content' => \array_map(fn (ToolInterface $tool): array => [
                'type' => 'tool_result',
                'tool_use_id' => $tool->getCallId(),
                'content' => $tool->getResult(),
            ], $message->getTools())
        ];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Ollama;

use NeuronAI\Chat\Attachments\Attachment;
use NeuronAI\Chat\Enums\AttachmentContentType;
use NeuronAI\Chat\Enums\AttachmentType;
use NeuronAI\Chat\Enums\MessageRole;
use NeuronAI\Chat\Messages\AssistantMessage;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\ToolCallMessage;
use NeuronAI\Chat\Messages\ToolCallResultMessage;
use NeuronAI\Chat\Messages\UserMessage;
use NeuronAI\Exceptions\ProviderException;
use NeuronAI\Providers\MessageMapperInterface;

class MessageMapper implements MessageMapperInterface
{
    /**
     * Mapped messages.
     */
    protected array $mapping = [];

    public function map(array $messages): array
    {
        $this->mapping = [];

        foreach ($messages as $message) {
            match ($message::class) {
                Message::class,
                UserMessage::class,
                AssistantMessage::class => $this->mapMessage($message),
                ToolCallMessage::class => $this->mapToolCall($message),
                ToolCallResultMessage::class => $this->mapToolsResult($message),
                default => throw new ProviderException('Could not map message type '.$message::class),
            };
        }

        return $this->mapping;
    }

    public function mapMessage(Message $message): void
    {
        $payload = $message->jsonSerialize();

        if (\array_key_exists('usage', $payload)) {
            unset($payload['usage']);
        }

        $attachments = $message->getAttachments();

        foreach ($attachments as $attachment) {
            if ($attachment->type === AttachmentType::DOCUMENT) {
                throw new ProviderException('This provider does not support document attachments.');
            }

            $payload['images'][] = $this->mapAttachment($attachment);
        }

        unset($payload['attachments']);

        $this->mapping[] = $payload;
    }

    protected function mapAttachment(Attachment $attachment): string
    {
        return match ($attachment->contentType) {
            AttachmentContentType::BASE64 => $attachment->content,
            AttachmentContentType::URL => throw new ProviderException('Ollama support only base64 attachment type.'),
        };
    }

    protected function mapToolCall(ToolCallMessage $message): void
    {
        $message = $message->jsonSerialize();

        if (\array_key_exists('usage', $message)) {
            unset($message['usage']);
        }

        if (\array_key_exists('tool_calls', $message)) {
            $message['tool_calls'] = \array_map(function (array $toolCall) {
                if (empty($toolCall['function']['arguments'])) {
                    $toolCall['function']['arguments'] = new \stdClass();
                }
                return $toolCall;
            }, $message['tool_calls']);
        }

        unset($message['type']);
        unset($message['tools']);

        $this->mapping[] = $message;
    }

    public function mapToolsResult(ToolCallResultMessage $message): void
    {
        foreach ($message->getTools() as $tool) {
            $this->mapping[] = [
                'role' => MessageRole::TOOL->value,
                'content' => $tool->getResult()
            ];
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\OpenAI;

use NeuronAI\Chat\Attachments\Attachment;
use NeuronAI\Chat\Enums\AttachmentContentType;
use NeuronAI\Chat\Enums\AttachmentType;
use NeuronAI\Chat\Enums\MessageRole;
use NeuronAI\Chat\Messages\AssistantMessage;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\ToolCallMessage;
use NeuronAI\Chat\Messages\ToolCallResultMessage;
use NeuronAI\Chat\Messages\UserMessage;
use NeuronAI\Exceptions\ProviderException;
use NeuronAI\Providers\MessageMapperInterface;

class MessageMapper implements MessageMapperInterface
{
    protected array $mapping = [];

    public function map(array $messages): array
    {
        $this->mapping = [];

        foreach ($messages as $message) {
            match ($message::class) {
                Message::class,
                UserMessage::class,
                AssistantMessage::class => $this->mapMessage($message),
                ToolCallMessage::class => $this->mapToolCall($message),
                ToolCallResultMessage::class => $this->mapToolsResult($message),
                default => throw new ProviderException('Could not map message type '.$message::class),
            };
        }

        return $this->mapping;
    }

    protected function mapMessage(Message $message): void
    {
        $payload = $message->jsonSerialize();

        if (\array_key_exists('usage', $payload)) {
            unset($payload['usage']);
        }

        $attachments = $message->getAttachments();

        if (\is_string($payload['content']) && $attachments) {
            $payload['content'] = [
                [
                    'type' => 'text',
                    'text' => $payload['content'],
                ],
            ];
        }

        foreach ($attachments as $attachment) {
            if ($attachment->type === AttachmentType::DOCUMENT) {
                throw new ProviderException('This provider does not support document attachments.');
            }

            $payload['content'][] = $this->mapAttachment($attachment);
        }

        unset($payload['attachments']);

        $this->mapping[] = $payload;
    }

    protected function mapAttachment(Attachment $attachment): array
    {
        return match($attachment->contentType) {
            AttachmentContentType::URL => [
                'type' => 'image_url',
                'image_url' => [
                    'url' => $attachment->content,
                ],
            ],
            AttachmentContentType::BASE64 => [
                'type' => 'image_url',
                'image_url' => [
                    'url' => 'data:'.$attachment->mediaType.';base64,'.$attachment->content,
                ],
            ]
        };
    }

    protected function mapToolCall(ToolCallMessage $message): void
    {
        $message = $message->jsonSerialize();

        if (\array_key_exists('usage', $message)) {
            unset($message['usage']);
        }

        unset($message['type']);
        unset($message['tools']);

        $this->mapping[] = $message;
    }

    protected function mapToolsResult(ToolCallResultMessage $message): void
    {
        foreach ($message->getTools() as $tool) {
            $this->mapping[] = [
                'role' => MessageRole::TOOL->value,
                'tool_call_id' => $tool->getCallId(),
                'content' => $tool->getResult()
            ];
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Ollama;

use GuzzleHttp\Promise\PromiseInterface;
use NeuronAI\Chat\Enums\MessageRole;
use NeuronAI\Chat\Messages\AssistantMessage;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\Usage;
use NeuronAI\Exceptions\ProviderException;
use Psr\Http\Message\ResponseInterface;

trait HandleChat
{
    public function chat(array $messages): Message
    {
        return $this->chatAsync($messages)->wait();
    }

    public function chatAsync(array $messages): PromiseInterface
    {
        // Include the system prompt
        if (isset($this->system)) {
            \array_unshift($messages, new Message(MessageRole::SYSTEM, $this->system));
        }

        $json = [
            'stream' => false,
            'model' => $this->model,
            'messages' => $this->messageMapper()->map($messages),
            ...$this->parameters,
        ];

        if (! empty($this->tools)) {
            $json['tools'] = $this->generateToolsPayload();
        }

        return $this->client->postAsync('chat', ['json' => $json])
            ->then(function (ResponseInterface $response): Message {
                if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) {
                    throw new ProviderException("Ollama chat error: {$response->getBody()->getContents()}");
                }

                $response = \json_decode($response->getBody()->getContents(), true);
                $message = $response['message'];

                if (\array_key_exists('tool_calls', $message)) {
                    $message = $this->createToolCallMessage($message);
                } else {
                    $message = new AssistantMessage($message['content']);
                }

                if (\array_key_exists('prompt_eval_count', $response) && \array_key_exists('eval_count', $response)) {
                    $message->setUsage(
                        new Usage($response['prompt_eval_count'], $response['eval_count'])
                    );
                }

                return $message;
            });
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Gemini;

use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\RequestOptions;
use NeuronAI\Exceptions\ProviderException;
use Psr\Http\Message\StreamInterface;

trait HandleStream
{
    /**
     * @throws ProviderException
     * @throws GuzzleException
     */
    public function stream(array|string $messages, callable $executeToolsCallback): \Generator
    {
        $json = [
            'contents' => $this->messageMapper()->map($messages),
            ...$this->parameters
        ];

        if (isset($this->system)) {
            $json['system_instruction'] = [
                'parts' => [
                    ['text' => $this->system]
                ]
            ];
        }

        if (!empty($this->tools)) {
            $json['tools'] = $this->generateToolsPayload();
        }

        $stream = $this->client->post(\trim($this->baseUri, '/')."/{$this->model}:streamGenerateContent", [
            'stream' => true,
            ...[RequestOptions::JSON => $json]
        ])->getBody();

        $text = '';
        $toolCalls = [];

        while (! $stream->eof()) {
            $line = $this->readLine($stream);

            if (($line = \json_decode((string) $line, true)) === null) {
                continue;
            }

            // Inform the agent about usage when stream
            if (\array_key_exists('usageMetadata', $line)) {
                yield \json_encode(['usage' => [
                    'input_tokens' => $line['usageMetadata']['promptTokenCount'],
                    'output_tokens' => $line['usageMetadata']['candidatesTokenCount'] ?? 0,
                ]]);
            }

            // Process tool calls
            if ($this->hasToolCalls($line)) {
                $toolCalls = $this->composeToolCalls($line, $toolCalls);

                // Handle tool calls
                if (isset($line['candidates'][0]['finishReason']) && $line['candidates'][0]['finishReason'] === 'STOP') {
                    yield from $executeToolsCallback(
                        $this->createToolCallMessage([
                            'content' => $text,
                            'parts' => $toolCalls
                        ])
                    );

                    return;
                }

                continue;
            }

            // Process regular content
            $content = $line['candidates'][0]['content']['parts'][0]['text'] ?? '';
            $text .= $content;

            yield $content;
        }
    }

    /**
     * Recreate the tool_calls format from streaming Gemini API.
     */
    protected function composeToolCalls(array $line, array $toolCalls): array
    {
        $parts = $line['candidates'][0]['content']['parts'] ?? [];

        foreach ($parts as $index => $part) {
            if (isset($part['functionCall'])) {
                $toolCalls[$index]['functionCall'] = $part['functionCall'];
            }
        }

        return $toolCalls;
    }

    /**
     * Determines if the given line contains tool function calls.
     *
     * @param array $line The data line to check for tool function calls.
     * @return bool Returns true if the line contains tool function calls, otherwise false.
     */
    protected function hasToolCalls(array $line): bool
    {
        $parts = $line['candidates'][0]['content']['parts'] ?? [];

        foreach ($parts as $part) {
            if (isset($part['functionCall'])) {
                return true;
            }
        }

        return false;
    }

    private function readLine(StreamInterface $stream): string
    {
        $buffer = '';

        while (! $stream->eof()) {
            $buffer .= $stream->read(1);

            if (\strlen($buffer) === 1 && $buffer !== '{') {
                $buffer = '';
            }

            if (\json_decode($buffer) !== null) {
                return $buffer;
            }
        }

        return \rtrim($buffer, ']');
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Gemini;

use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\RequestOptions;
use NeuronAI\Chat\Enums\MessageRole;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\Usage;
use Psr\Http\Message\ResponseInterface;

trait HandleChat
{
    public function chat(array $messages): Message
    {
        return $this->chatAsync($messages)->wait();
    }

    public function chatAsync(array $messages): PromiseInterface
    {
        $json = [
            'contents' => $this->messageMapper()->map($messages),
            ...$this->parameters
        ];

        if (isset($this->system)) {
            $json['system_instruction'] = [
                'parts' => [
                    ['text' => $this->system]
                ]
            ];
        }

        if (!empty($this->tools)) {
            $json['tools'] = $this->generateToolsPayload();
        }

        return $this->client->postAsync(\trim($this->baseUri, '/')."/{$this->model}:generateContent", [RequestOptions::JSON => $json])
            ->then(function (ResponseInterface $response): Message {
                $result = \json_decode($response->getBody()->getContents(), true);

                $content = $result['candidates'][0]['content'];

                if (!isset($content['parts']) && isset($result['candidates'][0]['finishReason']) && $result['candidates'][0]['finishReason'] === 'MAX_TOKENS') {
                    return new Message(MessageRole::from($content['role']), '');
                }

                $parts = $content['parts'];

                if (\array_key_exists('functionCall', $parts[0]) && !empty($parts[0]['functionCall'])) {
                    $response = $this->createToolCallMessage($content);
                } else {
                    $response = new Message(MessageRole::from($content['role']), $parts[0]['text'] ?? '');
                }

                // Attach the usage for the current interaction
                if (\array_key_exists('usageMetadata', $result)) {
                    $response->setUsage(
                        new Usage(
                            $result['usageMetadata']['promptTokenCount'],
                            $result['usageMetadata']['candidatesTokenCount'] ?? 0
                        )
                    );
                }

                return $response;
            });
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Gemini;

use NeuronAI\Chat\Attachments\Attachment;
use NeuronAI\Chat\Enums\AttachmentContentType;
use NeuronAI\Chat\Enums\MessageRole;
use NeuronAI\Chat\Messages\AssistantMessage;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\ToolCallMessage;
use NeuronAI\Chat\Messages\ToolCallResultMessage;
use NeuronAI\Chat\Messages\UserMessage;
use NeuronAI\Exceptions\ProviderException;
use NeuronAI\Providers\MessageMapperInterface;
use NeuronAI\Tools\ToolInterface;

class MessageMapper implements MessageMapperInterface
{
    protected array $mapping = [];

    public function map(array $messages): array
    {
        $this->mapping = [];

        foreach ($messages as $message) {
            match ($message::class) {
                Message::class,
                UserMessage::class,
                AssistantMessage::class => $this->mapMessage($message),
                ToolCallMessage::class => $this->mapToolCall($message),
                ToolCallResultMessage::class => $this->mapToolsResult($message),
                default => throw new ProviderException('Could not map message type '.$message::class),
            };
        }

        return $this->mapping;
    }

    protected function mapMessage(Message $message): void
    {
        $payload = [
            'role' => $message->getRole(),
            'parts' => [
                ['text' => $message->getContent()]
            ],
        ];

        $attachments = $message->getAttachments();

        foreach ($attachments as $attachment) {
            $payload['parts'][] = $this->mapAttachment($attachment);
        }

        $this->mapping[] = $payload;
    }

    protected function mapAttachment(Attachment $attachment): array
    {
        return match($attachment->contentType) {
            AttachmentContentType::URL => [
                'file_data' => [
                    'file_uri' => $attachment->content,
                    'mime_type' => $attachment->mediaType,
                ],
            ],
            AttachmentContentType::BASE64 => [
                'inline_data' => [
                    'data' => $attachment->content,
                    'mime_type' => $attachment->mediaType,
                ]
            ]
        };
    }

    protected function mapToolCall(ToolCallMessage $message): void
    {
        $this->mapping[] = [
            'role' => MessageRole::MODEL->value,
            'parts' => [
                ...\array_map(fn (ToolInterface $tool): array => [
                    'functionCall' => [
                        'name' => $tool->getName(),
                        'args' => $tool->getInputs() !== [] ? $tool->getInputs() : new \stdClass(),
                    ]
                ], $message->getTools())
            ]
        ];
    }

    protected function mapToolsResult(ToolCallResultMessage $message): void
    {
        $this->mapping[] = [
            'role' => MessageRole::USER->value,
            'parts' => \array_map(fn (ToolInterface $tool): array => [
                'functionResponse' => [
                    'name' => $tool->getName(),
                    'response' => [
                        'name' => $tool->getName(),
                        'content' => $tool->getResult(),
                    ],
                ],
            ], $message->getTools()),
        ];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\OpenAI;

use GuzzleHttp\Promise\PromiseInterface;
use NeuronAI\Chat\Enums\MessageRole;
use NeuronAI\Chat\Messages\AssistantMessage;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\Usage;
use Psr\Http\Message\ResponseInterface;

trait HandleChat
{
    public function chat(array $messages): Message
    {
        return $this->chatAsync($messages)->wait();
    }

    public function chatAsync(array $messages): PromiseInterface
    {
        // Include the system prompt
        if (isset($this->system)) {
            \array_unshift($messages, new Message(MessageRole::SYSTEM, $this->system));
        }

        $json = [
            'model' => $this->model,
            'messages' => $this->messageMapper()->map($messages),
            ...$this->parameters
        ];

        // Attach tools
        if (!empty($this->tools)) {
            $json['tools'] = $this->generateToolsPayload();
        }

        return $this->client->postAsync('chat/completions', ['json' => $json])
            ->then(function (ResponseInterface $response) {
                $result = \json_decode($response->getBody()->getContents(), true);

                if ($result['choices'][0]['finish_reason'] === 'tool_calls') {
                    $response = $this->createToolCallMessage($result['choices'][0]['message']);
                } else {
                    $response = new AssistantMessage($result['choices'][0]['message']['content']);
                }

                if (\array_key_exists('usage', $result)) {
                    $response->setUsage(
                        new Usage($result['usage']['prompt_tokens'], $result['usage']['completion_tokens'])
                    );
                }

                return $response;
            });
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Mistral;

use GuzzleHttp\Exception\GuzzleException;
use NeuronAI\Chat\Enums\MessageRole;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Exceptions\ProviderException;
use NeuronAI\Providers\OpenAI\OpenAI;

class Mistral extends OpenAI
{
    protected string $baseUri = 'https://api.mistral.ai/v1';

    /**
     * @throws ProviderException
     * @throws GuzzleException
     */
    public function stream(array|string $messages, callable $executeToolsCallback): \Generator
    {
        // Attach the system prompt
        if ($this->system !== null) {
            \array_unshift($messages, new Message(MessageRole::SYSTEM, $this->system));
        }

        $json = [
            'stream' => true,
            'model' => $this->model,
            'messages' => $this->messageMapper()->map($messages),
            ...$this->parameters,
        ];

        // Attach tools
        if ($this->tools !== []) {
            $json['tools'] = $this->generateToolsPayload();
        }

        $stream = $this->client->post('chat/completions', [
            'stream' => true,
            ...['json' => $json]
        ])->getBody();

        $text = '';
        $toolCalls = [];

        while (! $stream->eof()) {
            if (($line = $this->parseNextDataLine($stream)) === null) {
                continue;
            }

            // Inform the agent about usage when stream
            if (empty($line['choices']) && !empty($line['usage'])) {
                yield \json_encode(['usage' => [
                    'input_tokens' => $line['usage']['prompt_tokens'],
                    'output_tokens' => $line['usage']['completion_tokens'],
                ]]);
                continue;
            }

            if (empty($line['choices'])) {
                continue;
            }

            // Compile tool calls
            if ($this->isToolCallPart($line)) {
                $toolCalls = $this->composeToolCalls($line, $toolCalls);

                // Handle tool calls
                if ($line['choices'][0]['finish_reason'] === 'tool_calls') {
                    yield from $executeToolsCallback(
                        $this->createToolCallMessage([
                            'content' => $text,
                            'tool_calls' => $toolCalls
                        ])
                    );
                }

                continue;
            }

            // Process regular content
            $content = $line['choices'][0]['delta']['content'] ?? '';
            $text .= $content;

            yield $content;
        }
    }

    protected function isToolCallPart(array $line): bool
    {
        $calls = $line['choices'][0]['delta']['tool_calls'] ?? [];

        foreach ($calls as $call) {
            if (isset($call['function'])) {
                return true;
            }
        }

        return false;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI;

use NeuronAI\Chat\Messages\ToolCallMessage;
use NeuronAI\Chat\Messages\ToolCallResultMessage;
use NeuronAI\Exceptions\AgentException;
use NeuronAI\Observability\Events\AgentError;
use NeuronAI\Observability\Events\ToolCalled;
use NeuronAI\Observability\Events\ToolCalling;
use NeuronAI\Observability\Events\ToolsBootstrapped;
use NeuronAI\Tools\ToolInterface;
use NeuronAI\Tools\Toolkits\ToolkitInterface;

trait ResolveTools
{
    /**
     * Registered tools.
     *
     * @var ToolInterface[]|ToolkitInterface[]
     */
    protected array $tools = [];

    /**
     * @var ToolInterface[]
     */
    protected array $toolsBootstrapCache = [];

    /**
     * Get the list of tools.
     *
     * @return ToolInterface[]|ToolkitInterface[]
     */
    protected function tools(): array
    {
        return [];
    }

    /**
     * @return ToolInterface[]|ToolkitInterface[]
     */
    public function getTools(): array
    {
        return \array_merge($this->tools, $this->tools());
    }

    /**
     * If toolkits have already bootstrapped, this function
     * just traverses the array of tools without any action.
     *
     * @return ToolInterface[]
     */
    public function bootstrapTools(): array
    {
        $guidelines = [];

        if (!empty($this->toolsBootstrapCache)) {
            return $this->toolsBootstrapCache;
        }

        $this->notify('tools-bootstrapping');

        foreach ($this->getTools() as $tool) {
            if ($tool instanceof ToolkitInterface) {
                $kitGuidelines = $tool->guidelines();
                if ($kitGuidelines !== null && $kitGuidelines !== '') {
                    $name = (new \ReflectionClass($tool))->getShortName();
                    $kitGuidelines = '# '.$name.\PHP_EOL.$kitGuidelines;
                }

                // Merge the tools
                $innerTools = $tool->tools();
                $this->toolsBootstrapCache = \array_merge($this->toolsBootstrapCache, $innerTools);

                // Add guidelines to the system prompt
                if ($kitGuidelines !== null && $kitGuidelines !== '' && $kitGuidelines !== '0') {
                    $kitGuidelines .= \PHP_EOL.\implode(
                        \PHP_EOL.'- ',
                        \array_map(
                            fn (ToolInterface $tool): string => "{$tool->getName()}: {$tool->getDescription()}",
                            $innerTools
                        )
                    );

                    $guidelines[] = $kitGuidelines;
                }
            } else {
                // If the item is a simple tool, add to the list as it is
                $this->toolsBootstrapCache[] = $tool;
            }
        }

        $instructions = $this->removeDelimitedContent($this->resolveInstructions(), '<TOOLS-GUIDELINES>', '</TOOLS-GUIDELINES>');
        if ($guidelines !== []) {
            $this->withInstructions(
                $instructions.\PHP_EOL.'<TOOLS-GUIDELINES>'.\PHP_EOL.\implode(\PHP_EOL.\PHP_EOL, $guidelines).\PHP_EOL.'</TOOLS-GUIDELINES>'
            );
        }

        $this->notify('tools-bootstrapped', new ToolsBootstrapped($this->toolsBootstrapCache, $guidelines));

        return $this->toolsBootstrapCache;
    }

    /**
     * Add tools.
     *
     * @throws AgentException
     */
    public function addTool(ToolInterface|ToolkitInterface|array $tools): AgentInterface
    {
        $tools = \is_array($tools) ? $tools : [$tools];

        foreach ($tools as $t) {
            if (! $t instanceof ToolInterface && ! $t instanceof ToolkitInterface) {
                throw new AgentException('Tools must be an instance of ToolInterface or ToolkitInterface');
            }
            $this->tools[] = $t;
        }

        // Empty the cache for the next turn.
        $this->toolsBootstrapCache = [];

        return $this;
    }

    protected function executeTools(ToolCallMessage $toolCallMessage): ToolCallResultMessage
    {
        $toolCallResult = new ToolCallResultMessage($toolCallMessage->getTools());

        foreach ($toolCallResult->getTools() as $tool) {
            $this->notify('tool-calling', new ToolCalling($tool));
            try {
                $tool->execute();
            } catch (\Throwable $exception) {
                $this->notify('error', new AgentError($exception));
                throw $exception;
            }
            $this->notify('tool-called', new ToolCalled($tool));
        }

        return $toolCallResult;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Anthropic;

use GuzzleHttp\Exception\GuzzleException;
use NeuronAI\Exceptions\ProviderException;
use Psr\Http\Message\StreamInterface;

trait HandleStream
{
    /**
     * Stream response from the LLM.
     *
     * @throws ProviderException
     * @throws GuzzleException
     */
    public function stream(array|string $messages, callable $executeToolsCallback): \Generator
    {
        $json = [
            'stream' => true,
            'model' => $this->model,
            'max_tokens' => $this->max_tokens,
            'system' => $this->system ?? null,
            'messages' => $this->messageMapper()->map($messages),
            ...$this->parameters,
        ];

        if (!empty($this->tools)) {
            $json['tools'] = $this->generateToolsPayload();
        }

        // https://docs.anthropic.com/claude/reference/messages_post
        $stream = $this->client->post('messages', [
            'stream' => true,
            ...['json' => $json]
        ])->getBody();

        $toolCalls = [];

        while (! $stream->eof()) {
            if (!$line = $this->parseNextDataLine($stream)) {
                continue;
            }

            // https://docs.anthropic.com/en/api/messages-streaming
            if ($line['type'] === 'message_start') {
                yield \json_encode(['usage' => $line['message']['usage']]);
                continue;
            }

            if ($line['type'] === 'message_delta') {
                yield \json_encode(['usage' => $line['usage']]);
                continue;
            }

            // Tool calls detection (https://docs.anthropic.com/en/api/messages-streaming#streaming-request-with-tool-use)
            if (
                (isset($line['content_block']['type']) && $line['content_block']['type'] === 'tool_use') ||
                (isset($line['delta']['type']) && $line['delta']['type'] === 'input_json_delta')
            ) {
                $toolCalls = $this->composeToolCalls($line, $toolCalls);
                continue;
            }

            // Handle tool call
            if ($line['type'] === 'content_block_stop' && !empty($toolCalls)) {
                // Restore the input field as an array
                $toolCalls = \array_map(function (array $call) {
                    $call['input'] = \json_decode((string) $call['input'], true);
                    return $call;
                }, $toolCalls);

                yield from $executeToolsCallback(
                    $this->createToolCallMessage(\end($toolCalls))
                );
            }

            // Process regular content
            $content = $line['delta']['text'] ?? '';

            yield $content;
        }
    }

    /**
     * Recreate the tool_call format of anthropic API from streaming.
     *
     * @param  array<string, mixed>  $line
     * @param  array<int, array<string, mixed>>  $toolCalls
     * @return array<int, array<string, mixed>>
     */
    protected function composeToolCalls(array $line, array $toolCalls): array
    {
        if (!\array_key_exists($line['index'], $toolCalls)) {
            $toolCalls[$line['index']] = [
                'type' => 'tool_use',
                'id' => $line['content_block']['id'],
                'name' => $line['content_block']['name'],
                'input' => '',
            ];
        } elseif ($input = $line['delta']['partial_json'] ?? null) {
            $toolCalls[$line['index']]['input'] .= $input;
        }

        return $toolCalls;
    }

    protected function parseNextDataLine(StreamInterface $stream): ?array
    {
        $line = $this->readLine($stream);

        if (! \str_starts_with((string) $line, 'data:')) {
            return null;
        }

        $line = \trim(\substr((string) $line, \strlen('data: ')));

        try {
            return \json_decode($line, true, flags: \JSON_THROW_ON_ERROR);
        } catch (\Throwable $exception) {
            throw new ProviderException('Anthropic streaming error - '.$exception->getMessage());
        }
    }

    protected function readLine(StreamInterface $stream): string
    {
        $buffer = '';

        while (! $stream->eof()) {
            $byte = $stream->read(1);

            if ($byte === '') {
                return $buffer;
            }

            $buffer .= $byte;

            if ($byte === "\n") {
                break;
            }
        }

        return $buffer;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\OpenAI;

use GuzzleHttp\Exception\GuzzleException;
use NeuronAI\Chat\Enums\MessageRole;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Exceptions\ProviderException;
use Psr\Http\Message\StreamInterface;

trait HandleStream
{
    /**
     * @throws ProviderException
     * @throws GuzzleException
     */
    public function stream(array|string $messages, callable $executeToolsCallback): \Generator
    {
        // Attach the system prompt
        if (isset($this->system)) {
            \array_unshift($messages, new Message(MessageRole::SYSTEM, $this->system));
        }

        $json = [
            'stream' => true,
            'model' => $this->model,
            'messages' => $this->messageMapper()->map($messages),
            'stream_options' => ['include_usage' => true],
            ...$this->parameters,
        ];

        // Attach tools
        if (!empty($this->tools)) {
            $json['tools'] = $this->generateToolsPayload();
        }

        $stream = $this->client->post('chat/completions', [
            'stream' => true,
            ...['json' => $json]
        ])->getBody();

        $text = '';
        $toolCalls = [];

        while (! $stream->eof()) {
            if (!$line = $this->parseNextDataLine($stream)) {
                continue;
            }

            // Inform the agent about usage when stream
            if (!empty($line['usage'])) {
                yield \json_encode(['usage' => [
                    'input_tokens' => $line['usage']['prompt_tokens'],
                    'output_tokens' => $line['usage']['completion_tokens'],
                ]]);
            }

            if (empty($line['choices'])) {
                continue;
            }

            // Compile tool calls
            if (isset($line['choices'][0]['delta']['tool_calls'])) {
                $toolCalls = $this->composeToolCalls($line, $toolCalls);
                continue;
            }

            // Handle tool calls
            if (isset($line['choices'][0]['finish_reason']) && $line['choices'][0]['finish_reason'] === 'tool_calls') {
                yield from $executeToolsCallback(
                    $this->createToolCallMessage([
                        'content' => $text,
                        'tool_calls' => $toolCalls
                    ])
                );

                return;
            }

            // Process regular content
            $content = $line['choices'][0]['delta']['content'] ?? '';
            $text .= $content;

            yield $content;
        }
    }

    /**
     * Recreate the tool_calls format from streaming OpenAI API.
     *
     * @param  array<string, mixed>  $line
     * @param  array<int, array<string, mixed>>  $toolCalls
     * @return array<int, array<string, mixed>>
     */
    protected function composeToolCalls(array $line, array $toolCalls): array
    {
        foreach ($line['choices'][0]['delta']['tool_calls'] as $call) {
            $index = $call['index'];

            if (!\array_key_exists($index, $toolCalls)) {
                if ($name = $call['function']['name'] ?? null) {
                    $toolCalls[$index]['function'] = ['name' => $name, 'arguments' => $call['function']['arguments'] ?? ''];
                    $toolCalls[$index]['id'] = $call['id'];
                    $toolCalls[$index]['type'] = 'function';
                }
            } else {
                $arguments = $call['function']['arguments'] ?? null;
                if ($arguments !== null) {
                    $toolCalls[$index]['function']['arguments'] .= $arguments;
                }
            }
        }

        return $toolCalls;
    }

    protected function parseNextDataLine(StreamInterface $stream): ?array
    {
        $line = $this->readLine($stream);

        if (! \str_starts_with((string) $line, 'data:')) {
            return null;
        }

        $line = \trim(\substr((string) $line, \strlen('data: ')));

        if (\str_contains($line, 'DONE')) {
            return null;
        }

        try {
            return \json_decode($line, true, flags: \JSON_THROW_ON_ERROR);
        } catch (\Throwable $exception) {
            throw new ProviderException('OpenAI streaming error - '.$exception->getMessage());
        }
    }

    protected function readLine(StreamInterface $stream): string
    {
        $buffer = '';

        while (! $stream->eof()) {
            $byte = $stream->read(1);

            if ($byte === '') {
                return $buffer;
            }

            $buffer .= $byte;

            if ($byte === "\n") {
                break;
            }
        }

        return $buffer;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Anthropic;

use GuzzleHttp\Promise\PromiseInterface;
use NeuronAI\Chat\Messages\AssistantMessage;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\Usage;
use Psr\Http\Message\ResponseInterface;

trait HandleChat
{
    public function chat(array $messages): Message
    {
        return $this->chatAsync($messages)->wait();
    }

    public function chatAsync(array $messages): PromiseInterface
    {
        $json = [
            'model' => $this->model,
            'max_tokens' => $this->max_tokens,
            'messages' => $this->messageMapper()->map($messages),
            ...$this->parameters,
        ];

        if (isset($this->system)) {
            $json['system'] = $this->system;
        }

        if (!empty($this->tools)) {
            $json['tools'] = $this->generateToolsPayload();
        }

        return $this->client->postAsync('messages', ['json' => $json])
            ->then(function (ResponseInterface $response) {
                $result = \json_decode($response->getBody()->getContents(), true);

                $content = \end($result['content']);

                if ($content['type'] === 'tool_use') {
                    $response = $this->createToolCallMessage($content);
                } else {
                    $response = new AssistantMessage($content['text']);
                }

                // Attach the usage for the current interaction
                if (\array_key_exists('usage', $result)) {
                    $response->setUsage(
                        new Usage(
                            $result['usage']['input_tokens'],
                            $result['usage']['output_tokens']
                        )
                    );
                }

                return $response;
            });
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Ollama;

use NeuronAI\Chat\Enums\MessageRole;
use NeuronAI\Chat\Messages\Message;
use Psr\Http\Message\StreamInterface;

trait HandleStream
{
    public function stream(array|string $messages, callable $executeToolsCallback): \Generator
    {
        // Include the system prompt
        if (isset($this->system)) {
            \array_unshift($messages, new Message(MessageRole::SYSTEM, $this->system));
        }

        $json = [
            'stream' => true,
            'model' => $this->model,
            'messages' => $this->messageMapper()->map($messages),
            ...$this->parameters,
        ];

        if (!empty($this->tools)) {
            $json['tools'] = $this->generateToolsPayload();
        }

        $stream = $this->client->post('chat', [
            'stream' => true,
            ...['json' => $json]
        ])->getBody();

        while (! $stream->eof()) {
            if (!$line = $this->parseNextJson($stream)) {
                continue;
            }

            // Last chunk will contain the usage information.
            if ($line['done'] === true) {
                yield \json_encode(['usage' => [
                    'input_tokens' => $line['prompt_eval_count'],
                    'output_tokens' => $line['eval_count'],
                ]]);
                continue;
            }

            // Process tool calls
            if (isset($line['message']['tool_calls'])) {
                yield from $executeToolsCallback(
                    $this->createToolCallMessage($line['message'])
                );
            }

            // Process regular content
            $content = $line['message']['content'] ?? '';

            yield $content;
        }
    }

    protected function parseNextJson(StreamInterface $stream): ?array
    {
        $line = $this->readLine($stream);

        if (empty($line)) {
            return null;
        }

        $json = \json_decode((string) $line, true);

        if ($json['done']) {
            return null;
        }

        if (! isset($json['message']) || $json['message']['role'] !== 'assistant') {
            return null;
        }

        return $json;
    }

    protected function readLine(StreamInterface $stream): string
    {
        $buffer = '';

        while (! $stream->eof()) {
            if ('' === ($byte = $stream->read(1))) {
                return $buffer;
            }
            $buffer .= $byte;
            if ($byte === "\n") {
                break;
            }
        }

        return $buffer;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\OpenAI;

use GuzzleHttp\Client;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\ToolCallMessage;
use NeuronAI\Providers\HasGuzzleClient;
use NeuronAI\Providers\AIProviderInterface;
use NeuronAI\Providers\HandleWithTools;
use NeuronAI\Providers\MessageMapperInterface;
use NeuronAI\Tools\ToolInterface;
use NeuronAI\Tools\ToolPropertyInterface;

class OpenAI implements AIProviderInterface
{
    use HasGuzzleClient;
    use HandleWithTools;
    use HandleChat;
    use HandleStream;
    use HandleStructured;

    /**
     * The main URL of the provider API.
     */
    protected string $baseUri = 'https://api.openai.com/v1';

    /**
     * System instructions.
     * https://platform.openai.com/docs/api-reference/chat/create
     */
    protected ?string $system = null;

    public function __construct(
        protected string $key,
        protected string $model,
        protected array $parameters = [],
    ) {
        $this->client = new Client([
            'base_uri' => \trim($this->baseUri, '/').'/',
            'headers' => [
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
                'Authorization' => 'Bearer ' . $this->key,
            ]
        ]);
    }

    public function systemPrompt(?string $prompt): AIProviderInterface
    {
        $this->system = $prompt;
        return $this;
    }

    public function messageMapper(): MessageMapperInterface
    {
        return new MessageMapper();
    }

    protected function generateToolsPayload(): array
    {
        return \array_map(function (ToolInterface $tool): array {
            $payload = [
                'type' => 'function',
                'function' => [
                    'name' => $tool->getName(),
                    'description' => $tool->getDescription(),
                    'parameters' => [
                        'type' => 'object',
                        'properties' => new \stdClass(),
                        'required' => [],
                    ],
                ]
            ];

            $properties = \array_reduce($tool->getProperties(), function (array $carry, ToolPropertyInterface $property): array {
                $carry[$property->getName()] = $property->getJsonSchema();
                return $carry;
            }, []);

            if (!empty($properties)) {
                $payload['function']['parameters'] = [
                    'type' => 'object',
                    'properties' => $properties,
                    'required' => $tool->getRequiredProperties(),
                ];
            }

            return $payload;
        }, $this->tools);
    }

    protected function createToolCallMessage(array $message): Message
    {
        $tools = \array_map(
            fn (array $item): ToolInterface => $this->findTool($item['function']['name'])
                ->setInputs(
                    \json_decode((string) $item['function']['arguments'], true)
                )
                ->setCallId($item['id']),
            $message['tool_calls']
        );

        $result = new ToolCallMessage(
            $message['content'],
            $tools
        );

        return $result->addMetadata('tool_calls', $message['tool_calls']);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Gemini;

use GuzzleHttp\Client;
use NeuronAI\Chat\Enums\MessageRole;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\ToolCallMessage;
use NeuronAI\Providers\HasGuzzleClient;
use NeuronAI\Providers\AIProviderInterface;
use NeuronAI\Providers\HandleWithTools;
use NeuronAI\Providers\MessageMapperInterface;
use NeuronAI\Tools\ToolInterface;
use NeuronAI\Tools\ToolPropertyInterface;

class Gemini implements AIProviderInterface
{
    use HasGuzzleClient;
    use HandleWithTools;
    use HandleChat;
    use HandleStream;
    use HandleStructured;

    /**
     * The main URL of the provider API.
     */
    protected string $baseUri = 'https://generativelanguage.googleapis.com/v1beta/models';

    /**
     * System instructions.
     */
    protected ?string $system = null;

    public function __construct(
        protected string $key,
        protected string $model,
        protected array $parameters = [],
    ) {
        $this->client = new Client([
            // Since Gemini use colon ":" into the URL guzzle fire an exception using base_uri configuration.
            //'base_uri' => trim($this->baseUri, '/').'/',
            'headers' => [
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
                'x-goog-api-key' => $this->key,
            ]
        ]);
    }

    public function systemPrompt(?string $prompt): AIProviderInterface
    {
        $this->system = $prompt;
        return $this;
    }

    public function messageMapper(): MessageMapperInterface
    {
        return new MessageMapper();
    }

    protected function generateToolsPayload(): array
    {
        $tools = \array_map(function (ToolInterface $tool): array {
            $payload = [
                'name' => $tool->getName(),
                'description' => $tool->getDescription(),
                'parameters' => [
                    'type' => 'object',
                    'properties' => new \stdClass(),
                    'required' => [],
                ],
            ];

            $properties = \array_reduce($tool->getProperties(), function (array $carry, ToolPropertyInterface $property) {
                $carry[$property->getName()] = $property->getJsonSchema();
                return $carry;
            }, []);

            if (!empty($properties)) {
                $payload['parameters'] = [
                    'type' => 'object',
                    'properties' => $properties,
                    'required' => $tool->getRequiredProperties(),
                ];
            }

            return $payload;
        }, $this->tools);

        return [
            'functionDeclarations' => $tools
        ];
    }

    protected function createToolCallMessage(array $message): Message
    {
        $tools = \array_map(function (array $item): ?\NeuronAI\Tools\ToolInterface {
            if (!isset($item['functionCall'])) {
                return null;
            }

            // Gemini does not use ID. It uses the tool's name as a unique identifier.
            return $this->findTool($item['functionCall']['name'])
                ->setInputs($item['functionCall']['args'])
                ->setCallId($item['functionCall']['name']);
        }, $message['parts']);

        $result = new ToolCallMessage(
            $message['content'] ?? null,
            \array_filter($tools)
        );
        $result->setRole(MessageRole::MODEL);

        return $result;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Ollama;

use GuzzleHttp\Client;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\ToolCallMessage;
use NeuronAI\Providers\HasGuzzleClient;
use NeuronAI\Providers\AIProviderInterface;
use NeuronAI\Providers\HandleWithTools;
use NeuronAI\Providers\MessageMapperInterface;
use NeuronAI\Tools\ToolInterface;
use NeuronAI\Tools\ToolPropertyInterface;

class Ollama implements AIProviderInterface
{
    use HasGuzzleClient;
    use HandleWithTools;
    use HandleChat;
    use HandleStream;
    use HandleStructured;

    protected ?string $system = null;

    public function __construct(
        protected string $url, // http://localhost:11434/api
        protected string $model,
        protected array $parameters = [],
    ) {
        $this->client = new Client([
            'base_uri' => \trim($this->url, '/').'/',
        ]);
    }

    public function systemPrompt(?string $prompt): AIProviderInterface
    {
        $this->system = $prompt;

        return $this;
    }

    public function messageMapper(): MessageMapperInterface
    {
        return new MessageMapper();
    }

    protected function generateToolsPayload(): array
    {
        return \array_map(function (ToolInterface $tool): array {
            $payload = [
                'type' => 'function',
                'function' => [
                    'name' => $tool->getName(),
                    'description' => $tool->getDescription(),
                    'parameters' => [
                        'type' => 'object',
                        'properties' => new \stdClass(),
                        'required' => [],
                    ]
                ],
            ];

            $properties = \array_reduce($tool->getProperties(), function (array $carry, ToolPropertyInterface $property): array {
                $carry[$property->getName()] = [
                    'type' => $property->getType()->value,
                    'description' => $property->getDescription(),
                ];

                return $carry;
            }, []);

            if (! empty($properties)) {
                $payload['function']['parameters'] = [
                    'type' => 'object',
                    'properties' => $properties,
                    'required' => $tool->getRequiredProperties(),
                ];
            }

            return $payload;
        }, $this->tools);
    }

    protected function createToolCallMessage(array $message): Message
    {
        $tools = \array_map(fn (array $item): ToolInterface => $this->findTool($item['function']['name'])
            ->setInputs($item['function']['arguments']), $message['tool_calls']);

        $result = new ToolCallMessage(
            $message['content'],
            $tools
        );

        return $result->addMetadata('tool_calls', $message['tool_calls']);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability;

use Inspector\Configuration;
use Inspector\Inspector;
use Inspector\Models\Segment;
use NeuronAI\Agent;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Observability\Events\AgentError;
use NeuronAI\RAG\RAG;
use NeuronAI\Tools\ToolInterface;
use NeuronAI\Tools\Toolkits\ToolkitInterface;
use NeuronAI\Tools\ToolPropertyInterface;

/**
 * Trace your AI agent execution flow to detect errors and performance bottlenecks in real-time.
 *
 * Getting started with observability:
 * https://docs.neuron-ai.dev/advanced/observability
 */
class AgentMonitoring implements \SplObserver
{
    use HandleToolEvents;
    use HandleRagEvents;
    use HandleInferenceEvents;
    use HandleStructuredEvents;
    use HandleWorkflowEvents;

    public const SEGMENT_TYPE = 'neuron';
    public const SEGMENT_COLOR = '#FF800C';

    /**
     * @var array<string, Segment>
     */
    protected array $segments = [];

    /**
     * @var array<string, string>
     */
    protected array $methodsMap = [
        'error' => 'reportError',
        'chat-start' => 'start',
        'chat-stop' => 'stop',
        'stream-start' => 'start',
        'stream-stop' => 'stop',
        'structured-start' => 'start',
        'structured-stop' => 'stop',
        'chat-rag-start' => 'start',
        'chat-rag-stop' => 'stop',
        'stream-rag-start' => 'start',
        'stream-rag-stop' => 'stop',
        'structured-rag-start' => 'start',
        'structured-rag-stop' => 'stop',

        'message-saving' => 'messageSaving',
        'message-saved' => 'messageSaved',
        'tools-bootstrapping' => 'toolsBootstrapping',
        'tools-bootstrapped' => 'toolsBootstrapped',
        'inference-start' => 'inferenceStart',
        'inference-stop' => 'inferenceStop',
        'tool-calling' => 'toolCalling',
        'tool-called' => 'toolCalled',
        'schema-generation' => 'schemaGeneration',
        'schema-generated' => 'schemaGenerated',
        'structured-extracting' => 'extracting',
        'structured-extracted' => 'extracted',
        'structured-deserializing' => 'deserializing',
        'structured-deserialized' => 'deserialized',
        'structured-validating' => 'validating',
        'structured-validated' => 'validated',
        'rag-retrieving' => 'ragRetrieving',
        'rag-retrieved' => 'ragRetrieved',
        'rag-preprocessing' => 'preProcessing',
        'rag-preprocessed' => 'preProcessed',
        'rag-postprocessing' => 'postProcessing',
        'rag-postprocessed' => 'postProcessed',
        'workflow-start' => 'workflowStart',
        'workflow-end' => 'workflowEnd',
        'workflow-node-start' => 'workflowNodeStart',
        'workflow-node-end' => 'workflowNodeEnd',
    ];

    protected static ?AgentMonitoring $instance = null;

    /**
     * @param Inspector $inspector The monitoring instance
     */
    public function __construct(
        protected Inspector $inspector,
        protected bool $autoFlush = false,
    ) {
    }

    public static function instance(): AgentMonitoring
    {
        $configuration = new Configuration($_ENV['INSPECTOR_INGESTION_KEY']);
        $configuration->setTransport($_ENV['INSPECTOR_TRANSPORT'] ?? 'async');
        $configuration->setVersion($_ENV['INSPECTOR_VERSION'] ?? $configuration->getVersion());

        // Split monitoring between agents and workflows.
        if (isset($_ENV['NEURON_SPLIT_MONITORING'])) {
            return new self(new Inspector($configuration), $_ENV['NEURON_AUTOFLUSH'] ?? false);
        }

        if (!self::$instance instanceof AgentMonitoring) {
            self::$instance = new self(new Inspector($configuration), $_ENV['NEURON_AUTOFLUSH'] ?? false);
        }
        return self::$instance;
    }

    public function update(\SplSubject $subject, ?string $event = null, mixed $data = null): void
    {
        if (!\is_null($event) && \array_key_exists($event, $this->methodsMap)) {
            $method = $this->methodsMap[$event];
            $this->$method($subject, $event, $data);
        }
    }

    public function start(Agent $agent, string $event, mixed $data = null): void
    {
        if (!$this->inspector->isRecording()) {
            return;
        }

        $method = $this->getPrefix($event);
        $class = $agent::class;

        if ($this->inspector->needTransaction()) {
            $this->inspector->startTransaction($class.'::'.$method)
                ->setType('ai-agent')
                ->setContext($this->getContext($agent));
        } elseif ($this->inspector->canAddSegments() && !$agent instanceof RAG) { // do not add "parent" agent segments on RAG
            $key = $class.$method;

            if (\array_key_exists($key, $this->segments)) {
                $key .= '-'.\uniqid();
            }

            $segment = $this->inspector->startSegment(self::SEGMENT_TYPE.'-'.$method, "{$class}::{$method}")
                ->setColor(self::SEGMENT_COLOR);
            $segment->setContext($this->getContext($agent));
            $this->segments[$key] = $segment;
        }
    }

    /**
     * @throws \Exception
     */
    public function stop(Agent $agent, string $event, mixed $data = null): void
    {
        $method = $this->getPrefix($event);
        $class = $agent::class;

        if (\array_key_exists($class.$method, $this->segments)) {
            // End the last segment for the given method and agent class
            foreach (\array_reverse($this->segments, true) as $key => $segment) {
                if ($key === $class.$method) {
                    $segment->setContext($this->getContext($agent));
                    $segment->end();
                    unset($this->segments[$key]);
                    break;
                }
            }
        } elseif ($this->inspector->canAddSegments()) {
            $transaction = $this->inspector->transaction()->setResult('success');
            $transaction->setContext($this->getContext($agent));

            if ($this->autoFlush) {
                $this->inspector->flush();
            }
        }
    }

    public function reportError(\SplSubject $subject, string $event, AgentError $data): void
    {
        $this->inspector->reportException($data->exception, !$data->unhandled);

        if ($data->unhandled) {
            $this->inspector->transaction()->setResult('error');
            if ($subject instanceof Agent) {
                $this->inspector->transaction()->setContext($this->getContext($subject));
            }
        }
    }

    public function getPrefix(string $event): string
    {
        return \explode('-', $event)[0];
    }

    protected function getContext(Agent $agent): array
    {
        $mapTool = fn (ToolInterface $tool) => [
            $tool->getName() => [
                'description' => $tool->getDescription(),
                'properties' => \array_map(
                    fn (ToolPropertyInterface $property) => $property->jsonSerialize(),
                    $tool->getProperties()
                )
            ]
        ];

        return [
            'Agent' => [
                'provider' => $agent->resolveProvider()::class,
                'instructions' => $agent->resolveInstructions(),
            ],
            'Tools' => \array_map(
                fn (ToolInterface|ToolkitInterface $tool) => $tool instanceof ToolInterface
                    ? $mapTool($tool)
                    : [$tool::class => \array_map($mapTool, $tool->tools())],
                $agent->getTools()
            ),
            //'Messages' => $agent->resolveChatHistory()->getMessages(),
        ];
    }

    public function getMessageId(Message $message): string
    {
        $content = $message->getContent();

        if (!\is_string($content)) {
            $content = \json_encode($content, \JSON_UNESCAPED_UNICODE);
        }

        return \md5($content.$message->getRole());
    }

    protected function getBaseClassName(string $class): string
    {
        return \substr(\strrchr($class, '\\'), 1);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Anthropic;

use GuzzleHttp\Client;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\ToolCallMessage;
use NeuronAI\Providers\HasGuzzleClient;
use NeuronAI\Providers\AIProviderInterface;
use NeuronAI\Providers\HandleWithTools;
use NeuronAI\Providers\MessageMapperInterface;
use NeuronAI\Tools\ToolInterface;
use NeuronAI\Tools\ToolPropertyInterface;

class Anthropic implements AIProviderInterface
{
    use HasGuzzleClient;
    use HandleWithTools;
    use HandleChat;
    use HandleStream;
    use HandleStructured;

    /**
     * The main URL of the provider API.
     */
    protected string $baseUri = 'https://api.anthropic.com/v1/';

    /**
     * System instructions.
     * https://docs.anthropic.com/claude/docs/system-prompts#how-to-use-system-prompts
     */
    protected ?string $system = null;

    public function __construct(
        protected string $key,
        protected string $model,
        protected string $version = '2023-06-01',
        protected int $max_tokens = 8192,
        protected array $parameters = [],
    ) {
        $this->client = new Client([
            'base_uri' => \trim($this->baseUri, '/').'/',
            'headers' => [
                'Content-Type' => 'application/json',
                'x-api-key' => $this->key,
                'anthropic-version' => $version,
            ]
        ]);
    }

    public function systemPrompt(?string $prompt): AIProviderInterface
    {
        $this->system = $prompt;
        return $this;
    }

    public function messageMapper(): MessageMapperInterface
    {
        return new MessageMapper();
    }

    protected function generateToolsPayload(): array
    {
        return \array_map(function (ToolInterface $tool): array {
            $properties = \array_reduce($tool->getProperties(), function (array $carry, ToolPropertyInterface $property): array {
                $carry[$property->getName()] = $property->getJsonSchema();
                return $carry;
            }, []);

            return [
                'name' => $tool->getName(),
                'description' => $tool->getDescription(),
                'input_schema' => [
                    'type' => 'object',
                    'properties' => empty($properties) ? null : $properties,
                    'required' => $tool->getRequiredProperties(),
                ],
            ];
        }, $this->tools);
    }

    public function createToolCallMessage(array $content): Message
    {
        $tool = $this->findTool($content['name'])
            ->setInputs($content['input'])
            ->setCallId($content['id']);

        // During serialization and deserialization PHP convert the original empty object {} to empty array []
        // causing an error on the Anthropic API. If there are no inputs, we need to restore the empty JSON object.
        if (empty($content['input'])) {
            $content['input'] = new \stdClass();
        }

        return new ToolCallMessage(
            [$content],
            [$tool] // Anthropic call one tool at a time. So we pass an array with one element.
        );
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI;

use GuzzleHttp\Exception\RequestException;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\ToolCallMessage;
use NeuronAI\Chat\Messages\UserMessage;
use NeuronAI\Observability\Events\AgentError;
use NeuronAI\Observability\Events\Deserialized;
use NeuronAI\Observability\Events\Deserializing;
use NeuronAI\Observability\Events\Extracted;
use NeuronAI\Observability\Events\Extracting;
use NeuronAI\Observability\Events\InferenceStart;
use NeuronAI\Observability\Events\InferenceStop;
use NeuronAI\Exceptions\AgentException;
use NeuronAI\Observability\Events\SchemaGenerated;
use NeuronAI\Observability\Events\SchemaGeneration;
use NeuronAI\Observability\Events\Validated;
use NeuronAI\Observability\Events\Validating;
use NeuronAI\StructuredOutput\Deserializer\Deserializer;
use NeuronAI\StructuredOutput\JsonExtractor;
use NeuronAI\StructuredOutput\JsonSchema;
use NeuronAI\StructuredOutput\Validation\Validator;

trait HandleStructured
{
    /**
     * Enforce a structured response.
     *
     * @throws AgentException
     * @throws \ReflectionException
     * @throws \Throwable
     */
    public function structured(Message|array $messages, ?string $class = null, int $maxRetries = 1): mixed
    {
        $this->notify('structured-start');

        $this->fillChatHistory($messages);

        $tools = $this->bootstrapTools();

        // Get the JSON schema from the response model
        $class ??= $this->getOutputClass();
        $this->notify('schema-generation', new SchemaGeneration($class));
        $schema = (new JsonSchema())->generate($class);
        $this->notify('schema-generated', new SchemaGenerated($class, $schema));

        $error = '';
        do {
            try {
                // If something goes wrong, retry informing the model about the error
                if (\trim($error) !== '') {
                    $correctionMessage = new UserMessage(
                        "There was a problem in your previous response that generated the following errors".
                        \PHP_EOL.\PHP_EOL.'- '.$error.\PHP_EOL.\PHP_EOL.
                        "Try to generate the correct JSON structure based on the provided schema."
                    );
                    $this->fillChatHistory($correctionMessage);
                }

                $messages = $this->resolveChatHistory()->getMessages();

                $last = clone $this->resolveChatHistory()->getLastMessage();
                $this->notify(
                    'inference-start',
                    new InferenceStart($last)
                );
                $response = $this->resolveProvider()
                    ->systemPrompt($this->resolveInstructions())
                    ->setTools($tools)
                    ->structured($messages, $class, $schema);
                $this->notify(
                    'inference-stop',
                    new InferenceStop($last, $response)
                );

                $this->fillChatHistory($response);

                if ($response instanceof ToolCallMessage) {
                    $toolCallResult = $this->executeTools($response);
                    return self::structured($toolCallResult, $class, $maxRetries);
                }

                $output = $this->processResponse($response, $schema, $class);
                $this->notify('structured-stop');
                return $output;
            } catch (RequestException $exception) {
                $error = $exception->getResponse()?->getBody()->getContents() ?? $exception->getMessage();
                $this->notify('error', new AgentError($exception, false));
            } catch (\Exception $exception) {
                $error = $exception->getMessage();
                $this->notify('error', new AgentError($exception, false));
            }

            $maxRetries--;
        } while ($maxRetries >= 0);

        $exception = new AgentException(
            "Impossible to generate a structured response for the class {$class}: {$error}"
        );
        $this->notify('error', new AgentError($exception));
        throw $exception;
    }

    protected function processResponse(
        Message $response,
        array $schema,
        string $class,
    ): mixed {
        // Try to extract a valid JSON object from the LLM response
        $this->notify('structured-extracting', new Extracting($response));
        $json = (new JsonExtractor())->getJson($response->getContent());
        $this->notify('structured-extracted', new Extracted($response, $schema, $json));
        if ($json === null || $json === '') {
            throw new AgentException("The response does not contains a valid JSON Object.");
        }

        // Deserialize the JSON response from the LLM into an instance of the response model
        $this->notify('structured-deserializing', new Deserializing($class));
        $obj = Deserializer::fromJson($json, $class);
        $this->notify('structured-deserialized', new Deserialized($class));

        // Validate if the object fields respect the validation attributes
        // https://symfony.com/doc/current/validation.html#constraints
        $this->notify('structured-validating', new Validating($class, $json));

        $violations = Validator::validate($obj);

        if (\count($violations) > 0) {
            $this->notify('structured-validated', new Validated($class, $json, $violations));
            throw new AgentException(\PHP_EOL.'- '.\implode(\PHP_EOL.'- ', $violations));
        }
        $this->notify('structured-validated', new Validated($class, $json));

        return $obj;
    }

    protected function getOutputClass(): string
    {
        throw new AgentException('You need to specify an output class.');
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI;

use GuzzleHttp\Promise\PromiseInterface;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\ToolCallMessage;
use NeuronAI\Observability\Events\AgentError;
use NeuronAI\Observability\Events\InferenceStart;
use NeuronAI\Observability\Events\InferenceStop;

trait HandleChat
{
    /**
     * Execute the chat.
     *
     * @throws \Throwable
     */
    public function chat(Message|array $messages): Message
    {
        return $this->chatAsync($messages)->wait();
    }

    public function chatAsync(Message|array $messages): PromiseInterface
    {
        $this->notify('chat-start');

        $this->fillChatHistory($messages);

        $tools = $this->bootstrapTools();

        $this->notify(
            'inference-start',
            new InferenceStart($this->resolveChatHistory()->getLastMessage())
        );

        return $this->resolveProvider()
            ->systemPrompt($this->resolveInstructions())
            ->setTools($tools)
            ->chatAsync(
                $this->resolveChatHistory()->getMessages()
            )->then(function (Message $response): Message|PromiseInterface {
                $this->notify(
                    'inference-stop',
                    new InferenceStop($this->resolveChatHistory()->getLastMessage(), $response)
                );

                $this->fillChatHistory($response);

                if ($response instanceof ToolCallMessage) {
                    $toolCallResult = $this->executeTools($response);
                    return self::chatAsync($toolCallResult);
                }

                $this->notify('chat-stop');
                return $response;
            }, function (\Throwable $exception): void {
                $this->notify('error', new AgentError($exception));
                throw $exception;
            });
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\MCP;

class McpClient
{
    private McpTransportInterface $transport;

    private int $requestId = 0;

    /**
     * Create a new MCP client with the given transport
     */
    public function __construct(array $config)
    {
        if (\array_key_exists('command', $config)) {
            $this->transport = new StdioTransport($config);
            $this->transport->connect();
            $this->initialize();
        } else {
            // todo: implement support for SSE with URL config property
            throw new McpException('Transport not supported!');
        }
    }

    protected function initialize(): void
    {
        $request = [
            "jsonrpc" => "2.0",
            "id"      => ++$this->requestId,
            "method"  => "initialize",
            "params"  => [
                'protocolVersion' => '2024-11-05',
                'capabilities'    => (object)[
                    'sampling' => new \stdClass(),
                ],
                'clientInfo'      => (object)[
                    'name'    => 'neuron-ai',
                    'version' => '1.0.0',
                ],
            ],
        ];
        $this->transport->send($request);
        $response = $this->transport->receive();

        if ($response['id'] !== $this->requestId) {
            throw new McpException('Invalid response ID');
        }

        $request = [
            "jsonrpc" => "2.0",
            "method"  => "notifications/initialized",
        ];
        $this->transport->send($request);
    }

    /**
     * List all available tools from the MCP server
     *
     * @throws \Exception
     */
    public function listTools(): array
    {
        $tools = [];

        do {
            $request = [
                "jsonrpc" => "2.0",
                "id" => ++$this->requestId,
                "method" => "tools/list",
            ];

            // Eventually add pagination
            if (isset($response['result']['nextCursor'])) {
                $request['params'] = ['cursor' => $response['result']['nextCursor']];
            }

            $this->transport->send($request);
            $response = $this->transport->receive();

            if ($response['id'] !== $this->requestId) {
                throw new McpException('Invalid response ID');
            }

            $tools = \array_merge($tools, $response['result']['tools']);
        } while (isset($response['result']['nextCursor']));

        return $tools;
    }

    /**
     * Call a tool on the MCP server
     *
     * @throws \Exception
     */
    public function callTool(string $toolName, array $arguments = []): array
    {
        $request = [
            "jsonrpc" => "2.0",
            "id" => ++$this->requestId,
            "method" => "tools/call",
            "params" => [
                "name" => $toolName,
                "arguments" => $arguments
            ]
        ];

        $this->transport->send($request);
        return $this->transport->receive();
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput;

/**
 * Inspired by: https://github.com/cognesy/instructor-php
 */
class JsonExtractor
{
    protected array $extractors;

    public function __construct()
    {
        $this->extractors = [
            fn (string $text): array => [$text],                   // Try as it is
            fn (string $text): array => $this->findByMarkdown($text),
            fn (string $text): ?string => $this->findByBrackets($text),
            fn (string $text): array => $this->findJSONLikeStrings($text),
        ];
    }

    /**
     * Attempt to find and parse a complete valid JSON string in the input.
     * Returns a JSON-encoded string on success or an empty string on failure.
     */
    public function getJson(string $input): ?string
    {
        foreach ($this->extractors as $extractor) {
            $candidates = $extractor($input);
            if (empty($candidates)) {
                continue;
            }
            if (\is_string($candidates)) {
                $candidates = [$candidates];
            }

            foreach ($candidates as $candidate) {
                if (!\is_string($candidate)) {
                    continue;
                }
                if (\trim($candidate) === '') {
                    continue;
                }
                try {
                    $data = $this->tryParse($candidate);
                } catch (\Throwable) {
                    continue;
                }

                if ($data !== null) {
                    // Re-encode in canonical JSON form
                    $result = \json_encode($data);
                    if ($result !== false) {
                        return $result;
                    }
                }
            }
        }

        return null;
    }

    /**
     * Returns an associative array on success, or null if the parsing fails.
     *
     * @throws \JsonException
     */
    private function tryParse(string $maybeJson): ?array
    {
        $data = \json_decode($maybeJson, true, 512, \JSON_THROW_ON_ERROR);

        if ($data === false || $data === null || $data === '') {
            return null;
        }

        return $data;
    }

    /**
     * Find ALL fenced code blocks that start with ```json, and extract
     * the portion between the first '{' and the matching last '}' inside
     * that block. Return an array of candidates.
     */
    private function findByMarkdown(string $text): array
    {
        if (\trim($text) === '') {
            return [];
        }

        $candidates = [];
        $offset = 0;
        $fenceTag = '```json';

        while (($startFence = \strpos($text, $fenceTag, $offset)) !== false) {
            // Find the next triple-backtick fence AFTER the "```json"
            $closeFence = \strpos($text, '```', $startFence + \strlen($fenceTag));
            if ($closeFence === false) {
                // No closing fence found, stop scanning
                break;
            }

            // Substring that represents the code block between "```json" and "```"
            $codeBlock = \substr(
                $text,
                $startFence + \strlen($fenceTag),
                $closeFence - ($startFence + \strlen($fenceTag))
            );

            // Now find the first '{' and last '}' within this code block
            $firstBrace = \strpos($codeBlock, '{');
            $lastBrace = \strrpos($codeBlock, '}');
            if ($firstBrace !== false && $lastBrace !== false && $firstBrace < $lastBrace) {
                $jsonCandidate = \substr($codeBlock, $firstBrace, $lastBrace - $firstBrace + 1);
                $candidates[] = $jsonCandidate;
            }

            // Advance offset past the closing fence, so we can find subsequent code blocks
            $offset = $closeFence + 3; // skip '```'
        }

        return $candidates;
    }

    /**
     * Find a substring from the first '{' to the last '}'.
     */
    private function findByBrackets(string $text): ?string
    {
        $trimmed = \trim($text);
        if ($trimmed === '') {
            return null;
        }
        $firstOpen = \strpos($trimmed, '{');
        if ($firstOpen === 0 || $firstOpen === false) {
            return null;
        }

        $lastClose = \strrpos($trimmed, '}');
        if ($lastClose === false || $lastClose < $firstOpen) {
            return null;
        }

        return \substr($trimmed, $firstOpen, $lastClose - $firstOpen + 1);
    }

    /**
     * Scan through the text, capturing any substring that begins at '{'
     * and ends at its matching '}'—accounting for nested braces and strings.
     * Returns an array of all such candidates found.
     */
    private function findJSONLikeStrings(string $text): array
    {
        $text = \trim($text);
        if ($text === '') {
            return [];
        }

        $candidates = [];
        $currentCandidate = '';
        $bracketCount = 0;
        $inString = false;
        $escape = false;
        $len = \strlen($text);

        for ($i = 0; $i < $len; $i++) {
            $char = $text[$i];

            if (!$inString) {
                if ($char === '{') {
                    if ($bracketCount === 0) {
                        $currentCandidate = '';
                    }
                    $bracketCount++;
                } elseif ($char === '}') {
                    $bracketCount--;
                }
            }

            // Toggle inString if we encounter an unescaped quote
            if ($char === '"' && !$escape) {
                $inString = !$inString;
            }

            // Determine if current char is a backslash for next iteration
            $escape = ($char === '\\' && !$escape);

            if ($bracketCount > 0) {
                $currentCandidate .= $char;
            }

            // If bracketCount just went back to 0, we’ve closed a JSON-like block
            if ($bracketCount === 0 && $currentCandidate !== '') {
                $currentCandidate .= $char; // include the closing '}'
                $candidates[] = $currentCandidate;
                $currentCandidate = '';
            }
        }

        return $candidates;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\Messages;

use NeuronAI\Tools\ToolInterface;

/**
 * @method static static make(array|string|int|float|null $content = null, ToolInterface[] $tools)
 */
class ToolCallMessage extends AssistantMessage
{
    /**
     * @param array<ToolInterface> $tools
     */
    public function __construct(
        protected array|string|int|float|null $content,
        protected array $tools
    ) {
        parent::__construct($this->content);
    }

    /**
     * @return array<ToolInterface>
     */
    public function getTools(): array
    {
        return $this->tools;
    }

    public function jsonSerialize(): array
    {
        return \array_merge(
            parent::jsonSerialize(),
            [
                'type' => 'tool_call',
                'tools' => \array_map(fn (ToolInterface $tool): array => $tool->jsonSerialize(), $this->tools)
            ]
        );
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\MCP;

use NeuronAI\Exceptions\ArrayPropertyException;
use NeuronAI\Exceptions\ToolException;
use NeuronAI\StaticConstructor;
use NeuronAI\Tools\ArrayProperty;
use NeuronAI\Tools\ObjectProperty;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolInterface;
use NeuronAI\Tools\ToolProperty;

/**
 * @method static static make(array $config)
 */
class McpConnector
{
    use StaticConstructor;

    protected McpClient $client;

    /**
     * @var string[]
     */
    protected array $exclude = [];

    /**
     * @var string[]
     */
    protected array $only = [];

    public function __construct(array $config)
    {
        $this->client = new McpClient($config);
    }

    /**
     * @param  string[]  $tools
     */
    public function exclude(array $tools): McpConnector
    {
        $this->exclude = $tools;
        return $this;
    }

    /**
     * @param  string[]  $tools
     */
    public function only(array $tools): McpConnector
    {
        $this->only = $tools;
        return $this;
    }

    /**
     * Get the list of available Tools from the server.
     *
     * @return ToolInterface[]
     * @throws \Exception
     */
    public function tools(): array
    {
        // Filter by the only and exclude preferences.
        $tools = \array_filter(
            $this->client->listTools(),
            fn (array $tool): bool =>
                !\in_array($tool['name'], $this->exclude) &&
                ($this->only === [] || \in_array($tool['name'], $this->only)),
        );

        return \array_map(fn (array $tool): ToolInterface => $this->createTool($tool), $tools);
    }

    /**
     * Convert the list of tools from the MCP server to Neuron compatible entities.
     * @throws ArrayPropertyException
     * @throws \ReflectionException
     * @throws ToolException
     */
    protected function createTool(array $item): ToolInterface
    {
        $tool = Tool::make(
            name: $item['name'],
            description: $item['description'] ?? null
        )->setCallable(function (...$arguments) use ($item) {
            $response = \call_user_func($this->client->callTool(...), $item['name'], $arguments);

            if (\array_key_exists('error', $response)) {
                throw new McpException($response['error']['message']);
            }

            $response = $response['result']['content'][0];

            if ($response['type'] === 'text') {
                return $response['text'];
            }

            if ($response['type'] === 'image') {
                return $response;
            }

            throw new McpException("Tool response format not supported: {$response['type']}");
        });

        foreach ($item['inputSchema']['properties'] as $name => $prop) {
            $required = \in_array($name, $item['inputSchema']['required'] ?? []);

            $type = PropertyType::fromSchema($prop['type']);

            $property = match ($type) {
                PropertyType::ARRAY => $this->createArrayProperty($name, $required, $prop),
                PropertyType::OBJECT => $this->createObjectProperty($name, $required, $prop),
                default => $this->createToolProperty($name, $type, $required, $prop),
            };

            $tool->addProperty($property);
        }

        return $tool;
    }

    protected function createToolProperty(string $name, PropertyType $type, bool $required, array $prop): ToolProperty
    {
        return new ToolProperty(
            name: $name,
            type: $type,
            description: $prop['description'] ?? null,
            required: $required,
            enum: $prop['items']['enum'] ?? []
        );
    }

    /**
     * @throws ArrayPropertyException
     */
    protected function createArrayProperty(string $name, bool $required, array $prop): ArrayProperty
    {
        return new ArrayProperty(
            name: $name,
            description: $prop['description'] ?? null,
            required: $required,
            items: new ToolProperty(
                name: 'type',
                type: PropertyType::from($prop['items']['type'] ?? 'string'),
            )
        );
    }

    /**
     * @throws \ReflectionException
     */
    protected function createObjectProperty(string $name, bool $required, array $prop): ObjectProperty
    {
        return new ObjectProperty(
            name: $name,
            description: $prop['description'] ?? null,
            required: $required,
        );
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\Splitter;

use NeuronAI\RAG\Document;
use InvalidArgumentException;

/**
 * Splits text into sentences, groups into word-based chunks, and applies
 * overlap in terms of words.
 */
class SentenceTextSplitter extends AbstractSplitter
{
    private readonly int $maxWords;
    private readonly int $overlapWords;

    /**
     * @param int $maxWords    Maximum number of words per chunk
     * @param int $overlapWords Number of overlapping words between chunks
     */
    public function __construct(int $maxWords = 200, int $overlapWords = 0)
    {
        if ($overlapWords >= $maxWords) {
            throw new InvalidArgumentException('Overlap must be less than maxWords');
        }

        $this->maxWords = $maxWords;
        $this->overlapWords = $overlapWords;
    }

    /**
     * Splits text into word-based chunks, preserving sentence boundaries.
     *
     * @return Document[] Array of Document chunks
     */
    public function splitDocument(Document $document): array
    {
        // Split by paragraphs (2 or more newlines)
        $paragraphs = \preg_split('/\n{2,}/', $document->getContent());
        $chunks = [];
        $currentWords = [];

        foreach ($paragraphs as $paragraph) {
            $sentences = $this->splitSentences($paragraph);

            foreach ($sentences as $sentence) {
                $sentenceWords = $this->tokenizeWords($sentence);

                // If the sentence alone exceeds the limit, split it
                if (\count($sentenceWords) > $this->maxWords) {
                    if ($currentWords !== []) {
                        $chunks[] = \implode(' ', $currentWords);
                        $currentWords = [];
                    }
                    $chunks = \array_merge($chunks, $this->splitLongSentence($sentenceWords));
                    continue;
                }

                $candidateCount = \count($currentWords) + \count($sentenceWords);

                if ($candidateCount > $this->maxWords) {
                    if ($currentWords !== []) {
                        $chunks[] = \implode(' ', $currentWords);
                    }
                    $currentWords = $sentenceWords;
                } else {
                    $currentWords = \array_merge($currentWords, $sentenceWords);
                }
            }
        }

        if ($currentWords !== []) {
            $chunks[] = \implode(' ', $currentWords);
        }

        // Apply overlap only if necessary
        if ($this->overlapWords > 0) {
            $chunks = $this->applyOverlap($chunks);
        }

        $split = [];
        foreach ($chunks as $chunk) {
            $newDocument = new Document($chunk);
            $newDocument->sourceType = $document->getSourceType();
            $newDocument->sourceName = $document->getSourceName();
            $split[] = $newDocument;
        }

        return $split;
    }

    /**
     * Robust regex for sentence splitting (handles ., !, ?, …, periods followed by quotes, etc)
     */
    private function splitSentences(string $text): array
    {
        $pattern = '/(?<=[.!?…])\s+(?=(?:[\"\'\""\'\'«»„""]?)[A-ZÀ-Ÿ])/u';
        $sentences = \preg_split($pattern, \trim($text));
        return \array_filter(\array_map('trim', $sentences));
    }

    /**
     * Tokenizes text into words (simple whitespace split).
     *
     * @return string[] Array of words
     */
    private function tokenizeWords(string $text): array
    {
        return \preg_split('/\s+/u', \trim($text));
    }

    /**
     * Applies overlap of words between consecutive chunks.
     *
     * @param string[] $chunks
     * @return string[] Array of chunks with overlap applied
     */
    private function applyOverlap(array $chunks): array
    {
        if ($chunks === []) {
            return [];
        }

        $result = [$chunks[0]]; // First chunk remains unchanged
        $count = \count($chunks);

        for ($i = 1; $i < $count; $i++) {
            $prevWords = $this->tokenizeWords($chunks[$i - 1]);
            $curWords = $this->tokenizeWords($chunks[$i]);

            // Get only the words needed for overlap
            $overlap = \array_slice($prevWords, -$this->overlapWords);

            // Remove duplicate words at the beginning of current chunk
            $curWords = \array_slice($curWords, $this->overlapWords);

            $merged = \array_merge($overlap, $curWords);
            $result[] = \implode(' ', $merged);
        }

        return $result;
    }

    /**
     * Splits a long sentence into smaller chunks that respect the maxWords limit.
     *
     * @param string[] $words Array of words from the sentence
     * @return string[] Array of chunks
     */
    private function splitLongSentence(array $words): array
    {
        $chunks = [];
        $currentChunk = [];

        foreach ($words as $word) {
            if (\count($currentChunk) >= $this->maxWords) {
                $chunks[] = \implode(' ', $currentChunk);
                $currentChunk = [];
            }
            $currentChunk[] = $word;
        }

        if ($currentChunk !== []) {
            $chunks[] = \implode(' ', $currentChunk);
        }

        return $chunks;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore;

use NeuronAI\Exceptions\VectorStoreException;

class VectorSimilarity
{
    public static function cosineSimilarity(array $vector1, array $vector2): float|int
    {
        if (\count($vector1) !== \count($vector2)) {
            throw new VectorStoreException('Vectors must have the same length to apply cosine similarity.');
        }

        $dotProduct = 0.0;
        $magnitude1 = 0.0;
        $magnitude2 = 0.0;

        foreach ($vector1 as $key => $value) {
            if (isset($vector2[$key])) {
                $dotProduct += $value * $vector2[$key];
            }
            $magnitude1 += $value ** 2;
        }

        foreach ($vector2 as $value) {
            $magnitude2 += $value ** 2;
        }

        if ($magnitude1 === 0.0 || $magnitude2 === 0.0) {
            return 0.0;
        }

        return $dotProduct / (\sqrt($magnitude1) * \sqrt($magnitude2));
    }

    public static function cosineDistance(array $vector1, array $vector2): float|int
    {
        return 1 - self::cosineSimilarity($vector1, $vector2);
    }

    public static function similarityFromDistance(float|int $distance): float|int
    {
        return 1 - $distance;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability;

use NeuronAI\Agent;
use NeuronAI\Chat\Messages\Usage;
use NeuronAI\Observability\Events\InferenceStart;
use NeuronAI\Observability\Events\InferenceStop;
use NeuronAI\Observability\Events\MessageSaved;
use NeuronAI\Observability\Events\MessageSaving;

trait HandleInferenceEvents
{
    public function messageSaving(Agent $agent, string $event, MessageSaving $data): void
    {
        if (!$this->inspector->canAddSegments()) {
            return;
        }

        $label = $this->getBaseClassName($data->message::class);

        $this->segments[$this->getMessageId($data->message).'-save'] = $this->inspector
            ->startSegment(self::SEGMENT_TYPE.'-chathistory', "save_message( {$label} )")
            ->setColor(self::SEGMENT_COLOR);
    }

    public function messageSaved(Agent $agent, string $event, MessageSaved $data): void
    {
        $id = $this->getMessageId($data->message).'-save';

        if (!\array_key_exists($id, $this->segments)) {
            return;
        }

        $segment = $this->segments[$id];
        $segment->addContext('Message', \array_merge(
            $data->message->jsonSerialize(),
            $data->message->getUsage() instanceof Usage ? [
                'usage' => [
                    'input_tokens' => $data->message->getUsage()->inputTokens,
                    'output_tokens' => $data->message->getUsage()->outputTokens,
                ]
            ] : []
        ));
        $segment->end();
    }

    public function inferenceStart(Agent $agent, string $event, InferenceStart $data): void
    {
        if (!$this->inspector->canAddSegments() || $data->message === false) {
            return;
        }

        $label = $this->getBaseClassName($data->message::class);

        $this->segments[$this->getMessageId($data->message).'-inference'] = $this->inspector
            ->startSegment(self::SEGMENT_TYPE.'-inference', "inference( {$label} )")
            ->setColor(self::SEGMENT_COLOR);
    }

    public function inferenceStop(Agent $agent, string $event, InferenceStop $data): void
    {
        $id = $this->getMessageId($data->message).'-inference';

        if (\array_key_exists($id, $this->segments)) {
            $segment = $this->segments[$id]->end();
            $segment->addContext('Message', $data->message)
                ->addContext('Response', $data->response);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore;

use Http\Client\Exception;
use NeuronAI\RAG\Document;
use Typesense\Client;
use Typesense\Exceptions\ObjectNotFound;
use Typesense\Exceptions\TypesenseClientError;

class TypesenseVectorStore implements VectorStoreInterface
{
    public function __construct(
        protected Client $client,
        protected string $collection,
        protected int $vectorDimension,
        protected string $topK = '4',
    ) {
    }

    /**
     * @throws Exception
     * @throws TypesenseClientError
     */
    public function checkIndexStatus(Document $document): void
    {
        try {
            $this->client->collections[$this->collection]->retrieve();
            $this->checkVectorDimension(\count($document->getEmbedding()));
            return;
        } catch (ObjectNotFound) {
            $fields = [
                [
                    'name' => 'content',
                    'type' => 'string',
                ],
                [
                    'name' => 'sourceType',
                    'type' => 'string',
                    'facet' => true,
                ],
                [
                    'name' => 'sourceName',
                    'type' => 'string',
                    'facet' => true,
                ],
                [
                    'name' => 'embedding',
                    'type' => 'float[]',
                    'num_dim' => $this->vectorDimension,
                ],
            ];

            // Map custom fields
            foreach ($document->metadata as $name => $value) {
                $fields[] = [
                    'name' => $name,
                    'type' => \gettype($value),
                    'facet' => true,
                ];
            }

            $this->client->collections->create([
                'name' => $this->collection,
                'fields' => $fields,
            ]);
        }
    }

    public function addDocument(Document $document): void
    {
        if ($document->getEmbedding() === []) {
            throw new \Exception('document embedding must be set before adding a document');
        }

        $this->checkIndexStatus($document);

        $this->client->collections[$this->collection]->documents->create([
            'id' => $document->getId(), // Unique ID is required
            'content' => $document->getContent(),
            'embedding' => $document->getEmbedding(),
            'sourceType' => $document->getSourceType(),
            'sourceName' => $document->getSourceName(),
            ...$document->metadata,
        ]);
    }

    public function deleteBySource(string $sourceType, string $sourceName): void
    {
        $this->client->collections[$this->collection]->documents->delete([
            "filter_by" => "sourceType:={$sourceType} && sourceName:={$sourceName}",
        ]);
    }

    /**
     * Bulk save.
     *
     * @param Document[] $documents
     * @throws Exception
     * @throws \JsonException
     * @throws TypesenseClientError
     */
    public function addDocuments(array $documents): void
    {
        if ($documents === []) {
            return;
        }

        if (empty($documents[0]->getEmbedding())) {
            throw new \Exception('document embedding must be set before adding a document');
        }

        $this->checkIndexStatus($documents[0]);

        $lines = [];
        foreach ($documents as $document) {
            $lines[] = \json_encode([
                'id' => $document->getId(), // Unique ID is required
                'embedding' => $document->getEmbedding(),
                'content' => $document->getContent(),
                'sourceType' => $document->getSourceType(),
                'sourceName' => $document->getSourceName(),
                ...$document->metadata,
            ]);
        }

        $ndjson = \implode("\n", $lines);

        $this->client->collections[$this->collection]->documents->import($ndjson);
    }

    public function similaritySearch(array $embedding): array
    {
        $params = [
            'collection' => $this->collection,
            'q' => '*',
            'vector_query' => 'embedding:(' . \json_encode($embedding) . ')',
            'exclude_fields' => 'embedding',
            'per_page' => $this->topK,
            'num_candidates' => \max(50, \intval($this->topK) * 4),
        ];

        $searchRequests = ['searches' => [$params]];

        $response = $this->client->multiSearch->perform($searchRequests);
        return \array_map(function (array $hit): Document {
            $item = $hit['document'];
            $document = new Document($item['content']);
            //$document->embedding = $item['embedding']; // avoid carrying large data
            $document->sourceType = $item['sourceType'];
            $document->sourceName = $item['sourceName'];
            $document->score = VectorSimilarity::similarityFromDistance($hit['vector_distance']);

            foreach ($item as $name => $value) {
                if (!\in_array($name, ['content', 'sourceType', 'sourceName', 'score', 'embedding', 'id', 'vector_distance'])) {
                    $document->addMetadata($name, $value);
                }
            }

            return $document;
        }, $response['results'][0]['hits']);
    }

    private function checkVectorDimension(int $dimension): void
    {
        $schema = $this->client->collections[$this->collection]->retrieve();

        $embeddingField = null;

        foreach ($schema['fields'] as $field) {
            if ($field['name'] === 'embedding') {
                $embeddingField = $field;
                break;
            }
        }

        if (
            \array_key_exists('num_dim', $embeddingField)
            && $embeddingField['num_dim'] === $dimension
        ) {
            return;
        }

        throw new \Exception(
            "Vector embeddings dimension {$dimension} must be the same as the initial setup {$this->vectorDimension} - ".
            \json_encode($embeddingField)
        );
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore;

use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use NeuronAI\Exceptions\VectorStoreException;
use NeuronAI\RAG\Document;

class MeilisearchVectorStore implements VectorStoreInterface
{
    protected Client $client;

    public function __construct(
        string $indexUid,
        string $host = 'http://localhost:7700',
        ?string $key = null,
        protected string $embedder = 'default',
        protected int $topK = 5,
    ) {
        $this->client = new Client([
            'base_uri' => \trim($host, '/').'/indexes/'.$indexUid.'/',
            'headers' => [
                'Content-Type' => 'application/json',
                ...(\is_null($key) ? [] : ['Authorization' => "Bearer {$key}"])
            ]
        ]);

        try {
            $this->client->get('');
        } catch (\Exception) {
            throw new VectorStoreException("Index {$indexUid} doesn't exists. Remember to attach a custom embedder to the index in order to process vectors.");
        }
    }

    public function addDocument(Document $document): void
    {
        $this->addDocuments([$document]);
    }

    public function addDocuments(array $documents): void
    {
        $this->client->put('documents', [
            RequestOptions::JSON => \array_map(fn (Document $document) => [
                'id' => $document->getId(),
                'content' => $document->getContent(),
                'sourceType' => $document->getSourceType(),
                'sourceName' => $document->getSourceName(),
                ...$document->metadata,
                '_vectors' => [
                    'default' => [
                        'embeddings' => $document->getEmbedding(),
                        'regenerate' => false,
                    ],
                ]
            ], $documents),
        ]);
    }

    public function deleteBySource(string $sourceType, string $sourceName): void
    {
        $this->client->post('documents/delete', [
            RequestOptions::JSON => [
                'filter' => "sourceType = {$sourceType} AND sourceName = {$sourceName}",
            ]
        ]);
    }

    public function similaritySearch(array $embedding): iterable
    {
        $response = $this->client->post('search', [
            RequestOptions::JSON => [
                'vector' => $embedding,
                'limit' => \min($this->topK, 20),
                'retrieveVectors' => true,
                'showRankingScore' => true,
                'hybrid' => [
                    'semanticRatio' => 1.0,
                    'embedder' => $this->embedder,
                ],
            ]
        ])->getBody()->getContents();

        $response = \json_decode($response, true);

        return \array_map(function (array $item): Document {
            $document = new Document($item['content']);
            $document->id = $item['id'] ?? \uniqid();
            $document->sourceType = $item['sourceType'] ?? null;
            $document->sourceName = $item['sourceName'] ?? null;
            $document->embedding = $item['_vectors']['default']['embeddings'];
            $document->score = $item['_rankingScore'];

            foreach ($item as $name => $value) {
                if (!\in_array($name, ['_vectors', '_rankingScore', 'content', 'sourceType', 'sourceName', 'score', 'embedding', 'id'])) {
                    $document->addMetadata($name, $value);
                }
            }

            return $document;
        }, $response['hits']);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore;

use NeuronAI\Exceptions\VectorStoreException;
use NeuronAI\RAG\Document;

class FileVectorStore implements VectorStoreInterface
{
    public function __construct(
        protected string $directory,
        protected int $topK = 4,
        protected string $name = 'neuron',
        protected string $ext = '.store'
    ) {
        if (!\is_dir($this->directory)) {
            throw new VectorStoreException("Directory '{$this->directory}' does not exist");
        }
    }

    protected function getFilePath(): string
    {
        return $this->directory . \DIRECTORY_SEPARATOR . $this->name.$this->ext;
    }

    public function addDocument(Document $document): void
    {
        $this->addDocuments([$document]);
    }

    public function addDocuments(array $documents): void
    {
        $this->appendToFile(
            \array_map(fn (Document $document): array => $document->jsonSerialize(), $documents)
        );
    }

    public function deleteBySource(string $sourceType, string $sourceName): void
    {
        // Temporary file
        $tmpFile = $this->directory . \DIRECTORY_SEPARATOR . $this->name.'_tmp'.$this->ext;

        // Create a temporary file handle
        $tempHandle = \fopen($tmpFile, 'w');
        if (!$tempHandle) {
            throw new \RuntimeException("Cannot create temporary file: {$tmpFile}");
        }

        try {
            foreach ($this->getLine($this->getFilePath()) as $line) {
                $document = \json_decode((string) $line, true);

                if ($document['sourceType'] !== $sourceType || $document['sourceName'] !== $sourceName) {
                    \fwrite($tempHandle, (string) $line);
                }
            }
        } finally {
            \fclose($tempHandle);
        }

        // Replace the original file with the filtered version
        \unlink($this->getFilePath());
        if (!\rename($tmpFile, $this->getFilePath())) {
            throw new VectorStoreException(self::class." failed to replace original file.");
        }
    }

    public function similaritySearch(array $embedding): array
    {
        $topItems = [];

        foreach ($this->getLine($this->getFilePath()) as $document) {
            $document = \json_decode((string) $document, true);

            if (empty($document['embedding'])) {
                throw new VectorStoreException("Document with the following content has no embedding: {$document['content']}");
            }
            $dist = VectorSimilarity::cosineDistance($embedding, $document['embedding']);

            $topItems[] = ['dist' => $dist, 'document' => $document];

            \usort($topItems, fn (array $a, array $b): int => $a['dist'] <=> $b['dist']);

            if (\count($topItems) > $this->topK) {
                $topItems = \array_slice($topItems, 0, $this->topK, true);
            }
        }

        return \array_map(function (array $item): Document {
            $itemDoc = $item['document'];
            $document = new Document($itemDoc['content']);
            $document->embedding = $itemDoc['embedding'];
            $document->sourceType = $itemDoc['sourceType'];
            $document->sourceName = $itemDoc['sourceName'];
            $document->id = $itemDoc['id'];
            $document->score = VectorSimilarity::similarityFromDistance($item['dist']);
            $document->metadata = $itemDoc['metadata'] ?? [];

            return $document;
        }, $topItems);
    }

    protected function appendToFile(array $documents): void
    {
        \file_put_contents(
            $this->getFilePath(),
            \implode(\PHP_EOL, \array_map(fn (array $vector) => \json_encode($vector), $documents)).\PHP_EOL,
            \FILE_APPEND
        );
    }

    protected function getLine(string $filename): \Generator
    {
        $f = \fopen($filename, 'r');

        try {
            while ($line = \fgets($f)) {
                yield $line;
            }
        } finally {
            \fclose($f);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Gemini;

use NeuronAI\Chat\Enums\MessageRole;
use NeuronAI\Chat\Messages\Message;

trait HandleStructured
{
    public function structured(
        array $messages,
        string $class,
        array $response_format
    ): Message {
        if (!\array_key_exists('generationConfig', $this->parameters)) {
            $this->parameters['generationConfig'] = [
                'temperature' => 0,
            ];
        }

        // Gemini does not support structured output in combination with tools.
        // So we try to work with a JSON mode in case the agent has some tools defined.
        if (!empty($this->tools)) {
            $last_message = \end($messages);
            if ($last_message instanceof Message && $last_message->getRole() === MessageRole::USER->value) {
                $last_message->setContent(
                    $last_message->getContent() . ' Respond using this JSON schema: '.\json_encode($response_format)
                );
            }
        } else {
            // If there are no tools, we can enforce the structured output.
            $this->parameters['generationConfig']['response_schema'] = $this->adaptSchema($response_format);
            $this->parameters['generationConfig']['response_mime_type'] = 'application/json';
        }

        return $this->chat($messages);
    }

    /**
     * Gemini does not support additionalProperties attribute.
     */
    protected function adaptSchema(array $schema): array
    {
        if (\array_key_exists('additionalProperties', $schema)) {
            unset($schema['additionalProperties']);
        }

        foreach ($schema as $key => $value) {
            if (\is_array($value)) {
                $schema[$key] = $this->adaptSchema($value);
            }
        }

        return $schema;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Calculator;

use NeuronAI\Tools\ArrayProperty;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

class StandardDeviationTool extends Tool
{
    public function __construct(protected int $precision = 2, protected bool $sample = true)
    {
        parent::__construct(
            'calculate_standard_deviation',
            <<<DESC
Calculates the standard deviation, which measures how spread out the data points are from the mean.
A low standard deviation indicates data points are close to the mean, while a high standard deviation
indicates greater variability. Use this tool for risk assessment, quality control, measuring consistency,
or understanding data distribution. Choose sample (n-1) for sample data or population (n) for complete populations.
DESC
        );
    }

    protected function properties(): array
    {
        return [
            new ArrayProperty(
                name: 'numbers',
                description: 'Array of numerical values',
                required: true,
                items: new ToolProperty(
                    'number',
                    PropertyType::NUMBER,
                    'A numerical value',
                    true,
                )
            ),
        ];
    }

    public function __invoke(array $numbers): float|array
    {
        // Validate input
        if ($numbers === []) {
            return ['error' => 'Data array cannot be empty'];
        }

        // Filter and validate numeric values
        $numericData = \array_filter($numbers, fn (string|float|int $value): bool => \is_numeric($value));

        if ($numericData === []) {
            return ['error' => 'Data array must contain at least one numeric value'];
        }

        if (\count($numericData) === 1 && $this->sample) {
            return ['error' => 'Cannot calculate sample standard deviation with only one data point'];
        }

        // Convert to float values
        $numericData = \array_map('floatval', $numericData);

        // Calculate mean
        $mean = \array_sum($numericData) / \count($numericData);

        // Calculate the sum of squared differences
        $sumSquaredDifferences = 0;
        foreach ($numericData as $value) {
            $sumSquaredDifferences += ($value - $mean) ** 2;
        }

        // Calculate variance
        $divisor = $this->sample ? \count($numericData) - 1 : \count($numericData);
        $variance = $sumSquaredDifferences / $divisor;

        // Calculate standard deviation
        $standardDeviation = \sqrt($variance);

        return \round($standardDeviation, $this->precision);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Calculator;

use NeuronAI\Tools\ArrayProperty;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

class VarianceTool extends Tool
{
    public function __construct(protected int $precision = 2, protected bool $sample = true)
    {
        parent::__construct(
            'calculate_variance',
            <<<DESC
Calculates the variance, which measures the average squared deviation from the mean.
Variance quantifies how much the data points differ from the average value.
Use this tool for statistical analysis, understanding data spread, portfolio risk analysis,
or quality control measurements. The square root of variance gives the standard deviation.
Choose sample (n-1) for sample data or population (n) for complete populations.
DESC
        );
    }

    protected function properties(): array
    {
        return [
            new ArrayProperty(
                name: 'numbers',
                description: 'Array of numerical values',
                required: true,
                items: new ToolProperty(
                    'number',
                    PropertyType::NUMBER,
                    'A numerical value',
                    true,
                )
            ),
        ];
    }

    public function __invoke(array $numbers): float|array
    {
        // Validate input
        if ($numbers === []) {
            return ['error' => 'Data array cannot be empty'];
        }

        // Filter and validate numeric values
        $numericData = \array_filter($numbers, fn (string|float|int $value): bool => \is_numeric($value));

        if ($numericData === []) {
            return ['error' => 'Data array must contain at least one numeric value'];
        }

        if (\count($numericData) === 1 && $this->sample) {
            return ['error' => 'Cannot calculate sample variance with only one data point'];
        }

        // Convert to float values
        $numericData = \array_map('floatval', $numericData);

        // Calculate mean
        $mean = \array_sum($numericData) / \count($numericData);

        // Calculate the sum of squared differences
        $sumSquaredDifferences = 0;
        foreach ($numericData as $value) {
            $sumSquaredDifferences += ($value - $mean) ** 2;
        }

        // Calculate variance
        $divisor = $this->sample ? \count($numericData) - 1 : \count($numericData);
        $variance = $sumSquaredDifferences / $divisor;

        return \round($variance, $this->precision);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\Messages;

use NeuronAI\Tools\ToolInterface;

/**
 * @method static static make(ToolInterface[] $tools)
 */
class ToolCallResultMessage extends UserMessage
{
    /**
     * @param array<ToolInterface> $tools
     */
    public function __construct(protected array $tools)
    {
        parent::__construct(null);
    }

    /**
     * @return array<ToolInterface>
     */
    public function getTools(): array
    {
        return $this->tools;
    }

    public function jsonSerialize(): array
    {
        return \array_merge(
            parent::jsonSerialize(),
            [
                'type' => 'tool_call_result',
                'tools' => \array_map(fn (ToolInterface $tool): array => $tool->jsonSerialize(), $this->tools)
            ]
        );
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\MCP;

class StdioTransport implements McpTransportInterface
{
    /** @var null|resource|false $process */
    private mixed $process = null;
    private ?array $pipes = null;

    /**
     * Create a new StdioTransport with the given configuration
     */
    public function __construct(protected array $config)
    {
    }

    /**
     * Connect to the MCP server by spawning the process
     */
    public function connect(): void
    {
        $descriptorSpec = [
            0 => ["pipe", "r"],  // stdin
            1 => ["pipe", "w"],  // stdout
            2 => ["pipe", "w"]   // stderr
        ];

        $command = $this->config['command'];
        $args = $this->config['args'] ?? [];
        $env = $this->config['env'] ?? [];

        // Merge current environment with provided environment variables
        $fullEnv = \array_merge(\getenv(), $env);

        // Build command with arguments
        $commandLine = $command;
        foreach ($args as $arg) {
            $commandLine .= ' ' . \escapeshellarg((string) $arg);
        }

        // Start the process
        $this->process = \proc_open(
            $commandLine,
            $descriptorSpec,
            $this->pipes,
            null,
            $fullEnv
        );

        if (!\is_resource($this->process)) {
            throw new McpException("Failed to start the MCP server process");
        }

        // Configure pipes for binary data
        \stream_set_write_buffer($this->pipes[0], 0);
        \stream_set_read_buffer($this->pipes[1], 0);

        // Check that the process started successfully
        $status = \proc_get_status($this->process);
        if (!$status['running']) {
            $error = \stream_get_contents($this->pipes[2]);
            throw new McpException("Process failed to start: " . $error);
        }
    }

    /**
     * Send a request to the MCP server
     */
    public function send(array $data): void
    {
        if (!\is_resource($this->process)) {
            throw new McpException("Process is not running");
        }

        $status = \proc_get_status($this->process);
        if (!$status['running']) {
            throw new McpException("MCP server process is not running");
        }

        $jsonData = \json_encode($data);
        if ($jsonData === false) {
            throw new McpException("Failed to encode request data to JSON");
        }

        $bytesWritten = \fwrite($this->pipes[0], $jsonData . "\n");
        if ($bytesWritten === false || $bytesWritten < \strlen($jsonData) + 1) {
            throw new McpException("Failed to write complete request to MCP server");
        }

        \fflush($this->pipes[0]);
    }

    /**
     * Receive a response from the MCP server
     */
    public function receive(): array
    {
        if (!\is_resource($this->process)) {
            throw new McpException("Process is not running");
        }

        // Set stream to non-blocking mode
        \stream_set_blocking($this->pipes[1], false);

        $response = "";
        $startTime = \time();
        $timeout = 30; // 30-second timeout

        // Keep reading until we get a complete JSON response or timeout
        while (\time() - $startTime < $timeout) {
            $status = \proc_get_status($this->process);

            if (!$status['running']) {
                throw new McpException("MCP server process has terminated unexpectedly.");
            }

            $chunk = \fread($this->pipes[1], 4096);
            if ($chunk !== false && \strlen($chunk) > 0) {
                $response .= $chunk;

                // Try to parse what we have so far
                $decoded = \json_decode($response, true);
                if ($decoded !== null) {
                    // We've got a valid JSON response
                    return $decoded;
                }
            }

            // Small delay to prevent CPU spinning
            \usleep(10000); // 10ms
        }

        throw new McpException("Timeout waiting for response from MCP server");
    }

    /**
     * Disconnect from the MCP server
     */
    public function disconnect(): void
    {
        if (\is_resource($this->process)) {
            // Close all pipe handles
            foreach ($this->pipes as $pipe) {
                if (\is_resource($pipe)) {
                    \fclose($pipe);
                }
            }

            // Try graceful termination first
            $status = \proc_get_status($this->process);
            // On Unix systems, try sending SIGTERM
            if ($status['running'] && \function_exists('proc_terminate')) {
                \proc_terminate($this->process);
                // Give the process a moment to shut down gracefully
                \usleep(500000);
                // 500ms
            }

            // Close the process handle
            \proc_close($this->process);
            $this->process = null;
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools;

use NeuronAI\Exceptions\MissingCallbackParameter;
use NeuronAI\Exceptions\ToolCallableNotSet;
use NeuronAI\StaticConstructor;
use NeuronAI\StructuredOutput\Deserializer\Deserializer;
use NeuronAI\StructuredOutput\Deserializer\DeserializerException;

/**
 * @method static static make(?string $name = null, ?string $description = null, array $properties = [])
 */
class Tool implements ToolInterface
{
    use StaticConstructor;

    /**
     * @var ToolPropertyInterface[]
     */
    protected array $properties = [];

    /**
     * @var ?callable
     */
    protected $callback;

    /**
     * The arguments to pass in to the callback.
     */
    protected array $inputs = [];

    /**
     * The call ID generated by the LLM.
     */
    protected ?string $callId = null;

    /**
     * The result of the execution.
     */
    protected string|null $result = null;

    /**
     * Tool constructor.
     *
     * @param ToolPropertyInterface[] $properties
     */
    public function __construct(
        protected string $name,
        protected ?string $description = null,
        array $properties = []
    ) {
        if ($properties !== []) {
            $this->properties = $properties;
        }
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function addProperty(ToolPropertyInterface $property): ToolInterface
    {
        $this->properties[] = $property;
        return $this;
    }

    /**
     * @return ToolPropertyInterface[]
     */
    protected function properties(): array
    {
        return [];
    }

    /**
     * @return ToolPropertyInterface[]
     */
    public function getProperties(): array
    {
        if ($this->properties === []) {
            foreach ($this->properties() as $property) {
                $this->addProperty($property);
            }
        }

        return $this->properties;
    }

    public function getRequiredProperties(): array
    {
        return \array_reduce($this->getProperties(), function (array $carry, ToolPropertyInterface $property): array {
            if ($property->isRequired()) {
                $carry[] = $property->getName();
            }

            return $carry;
        }, []);
    }

    public function setCallable(callable $callback): self
    {
        $this->callback = $callback;
        return $this;
    }

    public function getInputs(): array
    {
        return $this->inputs ?? [];
    }

    public function setInputs(?array $inputs): self
    {
        $this->inputs = $inputs ?? [];
        return $this;
    }

    public function getCallId(): ?string
    {
        return $this->callId;
    }

    public function setCallId(?string $callId): self
    {
        $this->callId = $callId;
        return $this;
    }

    public function getResult(): string
    {
        return $this->result;
    }

    public function setResult(mixed $result): self
    {
        $this->result = \is_array($result) ? \json_encode($result) : (string) $result;

        return $this;
    }

    /**
     * Execute the client side function.
     *
     * @throws MissingCallbackParameter
     * @throws ToolCallableNotSet
     * @throws DeserializerException
     * @throws \ReflectionException
     */
    public function execute(): void
    {
        if (!\is_callable($this->callback) && !\method_exists($this, '__invoke')) {
            throw new ToolCallableNotSet('No function defined for tool execution.');
        }

        // Validate required parameters
        foreach ($this->getProperties() as $property) {
            if ($property->isRequired() && !\array_key_exists($property->getName(), $this->getInputs())) {
                throw new MissingCallbackParameter("Missing required parameter: {$property->getName()}");
            }
        }

        $parameters = \array_reduce($this->getProperties(), function (array $carry, ToolPropertyInterface $property) {
            $propertyName = $property->getName();
            $inputs = $this->getInputs();

            // Normalize missing optional properties by assigning them a null value
            // Treat it as explicitly null to ensure a consistent structure
            if (!\array_key_exists($propertyName, $inputs)) {
                $carry[$propertyName] = null;
                return $carry;
            }

            // Find the corresponding input value
            $inputValue = $inputs[$propertyName];

            // If there is an object property with a class definition,
            // deserialize the tool input into an instance of that class
            if ($property instanceof ObjectProperty && $property->getClass()) {
                $carry[$propertyName] = Deserializer::fromJson(\json_encode($inputValue), $property->getClass());
                return $carry;
            }

            // If a property is an array of objects and each item matches a class definition,
            // deserialize each item into an instance of that class
            if ($property instanceof ArrayProperty) {
                $items = $property->getItems();
                if ($items instanceof ObjectProperty && $items->getClass()) {
                    $class = $items->getClass();
                    $carry[$propertyName] = \array_map(fn (array|object $input): object => Deserializer::fromJson(\json_encode($input), $class), $inputValue);
                    return $carry;
                }
            }

            // No extra treatments for basic property types
            $carry[$propertyName] = $inputValue;
            return $carry;

        }, []);

        $this->setResult(
            \method_exists($this, '__invoke') ? $this->__invoke(...$parameters)
                : \call_user_func($this->callback, ...$parameters)
        );
    }

    public function jsonSerialize(): array
    {
        return [
            'name' => $this->name,
            'description' => $this->description,
            'inputs' => $this->inputs === [] ? new \stdClass() : $this->inputs,
            'callId' => $this->callId,
            'result' => $this->result,
        ];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI;

use NeuronAI\Chat\History\ChatHistoryInterface;
use NeuronAI\Chat\History\InMemoryChatHistory;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Observability\Events\MessageSaved;
use NeuronAI\Observability\Events\MessageSaving;

trait ResolveChatHistory
{
    protected ChatHistoryInterface $chatHistory;

    /**
     * Called on the agent instance.
     */
    public function withChatHistory(ChatHistoryInterface $chatHistory): self
    {
        $this->chatHistory = $chatHistory;
        return $this;
    }

    /**
     * Used extending the Agent.
     */
    protected function chatHistory(): ChatHistoryInterface
    {
        return new InMemoryChatHistory();
    }

    public function fillChatHistory(Message|array $messages): void
    {
        $messages = \is_array($messages) ? $messages : [$messages];

        foreach ($messages as $message) {
            $this->notify('message-saving', new MessageSaving($message));
            $this->resolveChatHistory()->addMessage($message);
            $this->notify('message-saved', new MessageSaved($message));
        }
    }

    /**
     * Get the current instance of the chat history.
     */
    public function resolveChatHistory(): ChatHistoryInterface
    {
        if (!isset($this->chatHistory)) {
            $this->chatHistory = $this->chatHistory();
        }

        return $this->chatHistory;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\Splitter;

use NeuronAI\RAG\Document;

class DelimiterTextSplitter extends AbstractSplitter
{
    public function __construct(private readonly int $maxLength = 1000, private readonly string $separator = ' ', private readonly int $wordOverlap = 0)
    {
    }

    /**
     * @return Document[]
     */
    public function splitDocument(Document $document): array
    {
        $text = $document->getContent();

        if ($text === '') {
            return [];
        }

        if (\strlen($text) <= $this->maxLength) {
            return [$document];
        }

        $parts = \explode($this->separator, $text);

        $chunks = $this->createChunksWithOverlap($parts);

        $split = [];
        foreach ($chunks as $chunk) {
            $newDocument = new Document($chunk);
            $newDocument->sourceType = $document->getSourceType();
            $newDocument->sourceName = $document->getSourceName();
            $split[] = $newDocument;
        }

        return $split;
    }

    /**
     * @param  array<string>  $words
     * @return array<string>
     */
    private function createChunksWithOverlap(array $words): array
    {
        $chunks = [];
        $currentChunk = [];
        $currentChunkLength = 0;
        foreach ($words as $word) {
            if ($word === '') {
                continue;
            }

            if ($currentChunkLength + \strlen($this->separator.$word) <= $this->maxLength || $currentChunk === []) {
                $currentChunk[] = $word;
                $currentChunkLength = $this->calculateChunkLength($currentChunk);
            } else {
                // Add the chunk with overlap
                $chunks[] = \implode($this->separator, $currentChunk);

                // Calculate overlap words
                $calculatedOverlap = \min($this->wordOverlap, \count($currentChunk) - 1);
                $overlapWords = $calculatedOverlap > 0 ? \array_slice($currentChunk, -$calculatedOverlap) : [];

                // Start a new chunk with overlap words
                $currentChunk = [...$overlapWords, $word];
                $currentChunk[0] = \trim($currentChunk[0]);
                $currentChunkLength = $this->calculateChunkLength($currentChunk);
            }
        }

        if ($currentChunk !== []) {
            $chunks[] = \implode($this->separator, $currentChunk);
        }

        return $chunks;
    }

    /**
     * @param  array<string>  $chunk
     */
    private function calculateChunkLength(array $chunk): int
    {
        return \array_sum(\array_map('strlen', $chunk)) + \count($chunk) * \strlen($this->separator) - 1;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput;

use ReflectionClass;
use ReflectionEnum;
use ReflectionEnumBackedCase;
use ReflectionException;
use ReflectionNamedType;
use ReflectionProperty;

class JsonSchema
{
    /**
     * Track classes being processed to prevent infinite recursion
     */
    private array $processedClasses = [];

    /**
     * Generate JSON schema from a PHP class
     *
     * @param string $class Fully qualified class name
     * @return array JSON schema definition
     * @throws ReflectionException
     */
    public function generate(string $class): array
    {
        // Reset processed classes for a new generation
        $this->processedClasses = [];

        // Generate the main schema
        return [
            ...$this->generateClassSchema($class),
            'additionalProperties' => false,
        ];
    }

    /**
     * Generate schema for a class
     *
     * @param string $class Class name
     * @return array The schema
     * @throws ReflectionException
     */
    private function generateClassSchema(string $class): array
    {
        $reflection = new ReflectionClass($class);

        // Check for circular reference
        if (\in_array($class, $this->processedClasses)) {
            // For circular references, return a simple object schema to break the cycle
            return ['type' => 'object'];
        }

        $this->processedClasses[] = $class;

        // Handle enum types differently
        if ($reflection->isEnum()) {
            $result = $this->processEnum(new ReflectionEnum($class));
            // Remove the class from the processed list after processing
            \array_pop($this->processedClasses);
            return $result;
        }

        // Create a basic object schema
        $schema = [
            'type' => 'object',
            'properties' => [],
        ];

        $requiredProperties = [];

        // Process all public properties
        $properties = $reflection->getProperties(ReflectionProperty::IS_PUBLIC);

        // Process each property
        foreach ($properties as $property) {
            $propertyName = $property->getName();

            $schema['properties'][$propertyName] = $this->processProperty($property);

            $attribute = $this->getPropertyAttribute($property);
            if ($attribute instanceof SchemaProperty && $attribute->required !== null) {
                if ($attribute->required) {
                    $requiredProperties[] = $propertyName;
                }
            } else {
                // If the attribute is not available,
                // use the default logic for required properties
                $type = $property->getType();

                $isNullable = $type ? $type->allowsNull() : true;

                if (!$isNullable && !$property->hasDefaultValue()) {
                    $requiredProperties[] = $propertyName;
                }
            }
        }

        // Add required properties
        if ($requiredProperties !== []) {
            $schema['required'] = $requiredProperties;
        }

        // Remove the class from the processed list after processing
        \array_pop($this->processedClasses);

        return $schema;
    }

    /**
     * Process a single property to generate its schema
     *
     * @return array Property schema
     * @throws ReflectionException
     */
    private function processProperty(ReflectionProperty $property): array
    {
        $schema = [];

        // Process Property attribute if present
        $attribute = $this->getPropertyAttribute($property);
        if ($attribute instanceof SchemaProperty) {
            if ($attribute->title !== null) {
                $schema['title'] = $attribute->title;
            }

            if ($attribute->description !== null) {
                $schema['description'] = $attribute->description;
            }
        }

        /** @var ?ReflectionNamedType $type */
        $type = $property->getType();
        $typeName = $type?->getName();

        // Handle default values
        if ($property->hasDefaultValue()) {
            $schema['default'] = $property->getDefaultValue();
        }

        // Process different types
        if ($typeName === 'array') {
            $schema['type'] = 'array';

            // Parse PHPDoc for the array item type
            $docComment = $property->getDocComment();
            if ($docComment) {
                // Extract type from both "@var \App\Type[]" and "@var array<\App\Type>"
                \preg_match('/@var\s+(?:([a-zA-Z0-9_\\\\]+)\[\]|array<([a-zA-Z0-9_\\\\]+)>)/', $docComment, $matches);

                if (isset($matches[1]) || isset($matches[2])) {
                    $itemType = empty($matches[1]) ? ((isset($matches[2]) && $matches[2] !== '0') ? $matches[2] : null) : ($matches[1]);

                    // Handle class type for array items
                    if (\class_exists($itemType) || \enum_exists($itemType)) {
                        $schema['items'] = $this->generateClassSchema($itemType);
                    } else {
                        // Basic type
                        $schema['items'] = $this->getBasicTypeSchema($itemType);
                    }
                } else {
                    // Default to string if no specific type found
                    $schema['items'] = ['type' => 'string'];
                }
            } else {
                // Default to string if no doc comment
                $schema['items'] = ['type' => 'string'];
            }
        }
        // Handle enum types
        elseif ($typeName && \enum_exists($typeName)) {
            $enumReflection = new ReflectionEnum($typeName);
            $schema = \array_merge($schema, $this->processEnum($enumReflection));
        }
        // Handle class types
        elseif ($typeName && \class_exists($typeName)) {
            $classSchema = $this->generateClassSchema($typeName);
            $schema = \array_merge($schema, $classSchema);
        }
        // Handle basic types
        elseif ($typeName) {
            $typeSchema = $this->getBasicTypeSchema($typeName);
            $schema = \array_merge($schema, $typeSchema);
        } else {
            // Default to string if no type hint
            $schema['type'] = 'string';
        }

        // Handle nullable types - for basic types only
        if ($type && $type->allowsNull() && isset($schema['type']) && !isset($schema['$ref']) && !isset($schema['allOf'])) {
            if (\is_array($schema['type'])) {
                if (!\in_array('null', $schema['type'])) {
                    $schema['type'][] = 'null';
                }
            } else {
                $schema['type'] = [$schema['type'], 'null'];
            }
        }

        return $schema;
    }

    /**
     * Process an enum to generate its schema
     */
    private function processEnum(ReflectionEnum $enum): array
    {
        // Create enum schema
        $schema = [
            'type' => 'string',
            'enum' => [],
        ];

        // Extract enum values
        foreach ($enum->getCases() as $case) {
            if ($enum->isBacked()) {
                /** @var ReflectionEnumBackedCase $case */
                // For backed enums, use the backing value
                $schema['enum'][] = $case->getBackingValue();
            } else {
                // For non-backed enums, use case name
                $schema['enum'][] = $case->getName();
            }
        }

        return $schema;
    }

    /**
     * Get the Property attribute if it exists on a property
     */
    private function getPropertyAttribute(ReflectionProperty $property): ?SchemaProperty
    {
        $attributes = $property->getAttributes(SchemaProperty::class);
        if ($attributes !== []) {
            return $attributes[0]->newInstance();
        }
        return null;
    }

    /**
     * Get schema for a basic PHP type
     *
     * @param string $type PHP type name
     * @return array Schema for the type
     * @throws ReflectionException
     */
    private function getBasicTypeSchema(string $type): array
    {
        switch ($type) {
            case 'string':
                return ['type' => 'string'];

            case 'int':
            case 'integer':
                return ['type' => 'integer'];

            case 'float':
            case 'double':
                return ['type' => 'number'];

            case 'bool':
            case 'boolean':
                return ['type' => 'boolean'];

            case 'array':
                return [
                    'type' => 'array',
                    'items' => ['type' => 'string'],
                ];

            default:
                // Check if it's a class or enum
                if (\class_exists($type)) {
                    return $this->generateClassSchema($type);
                }
                // Check if it's a class or enum
                if (\enum_exists($type)) {
                    return $this->processEnum(new ReflectionEnum($type));
                }

                // Default to string for unknown types
                return ['type' => 'string'];
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

use NeuronAI\StructuredOutput\StructuredOutputException;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class Count extends AbstractValidationRule
{
    public function __construct(
        protected ?int $exactly = null,
        protected ?int $min = null,
        protected ?int $max = null,
    ) {
    }

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if (null !== $this->exactly && null === $this->min && null === $this->max) {
            $this->min = $this->max = $this->exactly;
        }

        if (null === $this->min && null === $this->max) {
            throw new StructuredOutputException('Either option "min" or "max" must be given for validation rule "Length"');
        }

        if (\is_null($value) && ($this->min > 0 || $this->exactly > 0)) {
            $violations[] = $this->buildMessage($name, '{name} cannot be empty');
            return;
        }

        if (!\is_array($value) && !$value instanceof \Countable) {
            throw new StructuredOutputException($name. ' must be an array or a Countable object');
        }

        $count = \count($value);

        if (null !== $this->max && $count > $this->max) {
            $shouldExact = $this->min == $this->max;

            if ($shouldExact) {
                $violations[] = $this->buildMessage($name, '{name} must be exactly {exact} items long', ['exact' => $this->min]);
            } else {
                $violations[] = $this->buildMessage($name, '{name} is too long. It must be at most {max} items', ['max' => $this->max]);
            }
        }

        if (null !== $this->min && $count < $this->min) {
            $shouldExact = $this->min == $this->max;

            if ($shouldExact) {
                $violations[] = $this->buildMessage($name, '{name} must be exactly {exact} items long', ['exact' => $this->min]);
            } else {
                $violations[] = $this->buildMessage($name, '{name} is too short. It must be at least {min} items', ['min' => $this->min]);
            }
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Deserializer;

use BackedEnum;

class Deserializer
{
    /**
     * Deserialize JSON data into a specified class instance
     *
     * @param  string  $jsonData  The JSON string to deserialize
     * @param  string  $className  The fully qualified class name to instantiate
     * @return object Instance of the specified class
     * @throws DeserializerException|\ReflectionException
     */
    public static function fromJson(string $jsonData, string $className): object
    {
        // Decode JSON data
        $data = \json_decode($jsonData, true);

        if (\json_last_error() !== \JSON_ERROR_NONE) {
            throw new DeserializerException('Invalid JSON: '.\json_last_error_msg());
        }

        return self::deserializeObject($data, $className);
    }

    /**
     * Deserialize an array/object into a class instance
     *
     * @param  array  $data  The data to deserialize
     * @param  string  $className  The target class name
     * @throws DeserializerException|\ReflectionException
     */
    private static function deserializeObject(array $data, string $className): object
    {
        if (!\class_exists($className)) {
            throw new DeserializerException("Class {$className} does not exist");
        }

        $reflection = new \ReflectionClass($className);

        // Create an instance without calling constructor
        $instance = $reflection->newInstanceWithoutConstructor();

        // Get all properties including private/protected
        $properties = $reflection->getProperties();

        foreach ($properties as $property) {
            $propertyName = $property->getName();

            // Check if data contains this property (case-sensitive and snake_case/camelCase variants)
            $value = self::findPropertyValue($data, $propertyName);

            if ($value !== null) {
                // Get property type information
                $type = $property->getType();

                if ($type) {
                    $value = self::castValue($value, $type, $property);
                }

                $property->setValue($instance, $value);
            }
        }

        // Call constructor if it exists and is public
        $constructor = $reflection->getConstructor();
        if ($constructor && $constructor->isPublic() && $constructor->getNumberOfRequiredParameters() === 0) {
            $constructor->invoke($instance);
        }

        return $instance;
    }

    /**
     * Find property value in data, supporting different naming conventions
     */
    private static function findPropertyValue(array $data, string $propertyName): mixed
    {
        // Direct match
        if (\array_key_exists($propertyName, $data)) {
            return $data[$propertyName];
        }

        // Convert camelCase to snake_case
        $snakeCase = \strtolower((string) \preg_replace('/(?<!^)[A-Z]/', '_$0', $propertyName));
        if (\array_key_exists($snakeCase, $data)) {
            return $data[$snakeCase];
        }

        // Convert snake_case to camelCase
        $camelCase = \lcfirst(\str_replace('_', '', \ucwords($propertyName, '_')));
        if (\array_key_exists($camelCase, $data)) {
            return $data[$camelCase];
        }

        return null;
    }

    /**
     * Cast value to the appropriate type based on property type
     *
     * @throws DeserializerException|\ReflectionException
     */
    private static function castValue(mixed $value, \ReflectionType $type, \ReflectionProperty $property): mixed
    {
        if ($type instanceof \ReflectionUnionType) {
            // Handle union types (PHP 8+)
            foreach ($type->getTypes() as $unionType) {
                try {
                    return self::castToSingleType($value, $unionType, $property);
                } catch (\Exception) {
                    continue;
                }
            }
            throw new DeserializerException("Cannot cast value to any type in union for property {$property->getName()}");
        }

        // @phpstan-ignore-next-line
        return self::castToSingleType($value, $type, $property);
    }

    /**
     * Cast value to a single type
     *
     * @throws DeserializerException|\ReflectionException
     */
    private static function castToSingleType(
        mixed $value,
        \ReflectionNamedType $type,
        \ReflectionProperty $property
    ): mixed {
        $typeName = $type->getName();

        // Handle null values
        if ($value === null) {
            if ($type->allowsNull()) {
                return null;
            }
            throw new DeserializerException("Property {$property->getName()} does not allow null values");
        }

        return match ($typeName) {
            'string' => (string) $value,
            'int' => (int) $value,
            'float' => (float) $value,
            'bool' => (bool) $value,
            'array' => self::handleArray($value, $property),
            'DateTime' => self::createDateTime($value),
            'DateTimeImmutable' => self::createDateTimeImmutable($value),
            default => self::handleSingleObject($value, $typeName)
        };
    }

    /**
     * @throws DeserializerException|\ReflectionException
     */
    private static function handleSingleObject(mixed $value, string $typeName): mixed
    {
        if (\is_array($value) && \class_exists($typeName)) {
            return self::deserializeObject($value, $typeName);
        }

        if (\enum_exists($typeName)) {
            return self::handleEnum($typeName, $value);
        }

        // Fallback: return the value as-is
        return $value;
    }

    /**
     * Handle collections
     *
     * @throws DeserializerException|\ReflectionException
     */
    private static function handleArray(mixed $value, \ReflectionProperty $property): mixed
    {
        // Handle arrays of objects using docblock annotations
        if (self::isArrayOfObjects($property)) {
            $elementType = self::getArrayElementType($property);
            if ($elementType && \class_exists($elementType)) {
                return \array_map(fn (array $item): object => self::deserializeObject($item, $elementType), $value);
            }
        }

        // Fallback: return the value as-is
        return $value;
    }

    /**
     * Check if a property represents an array of objects based on docblock
     */
    private static function isArrayOfObjects(\ReflectionProperty $property): bool
    {
        $docComment = $property->getDocComment();
        if (!$docComment) {
            return false;
        }

        return \preg_match('/@var\s+(?:([a-zA-Z0-9_\\\\]+)\[\]|array<([a-zA-Z0-9_\\\\]+)>)/', $docComment) === 1;
    }

    /**
     * Extract an element type from array docblock annotation
     */
    private static function getArrayElementType(\ReflectionProperty $property): ?string
    {
        $docComment = $property->getDocComment();
        if (!$docComment) {
            return null;
        }

        // Extract type from both "@var \App\Type[]" and "@var array<\App\Type>"
        if (\preg_match('/@var\s+(?:([a-zA-Z0-9_\\\\]+)\[\]|array<([a-zA-Z0-9_\\\\]+)>)/', $docComment, $matches) === 1) {
            return empty($matches[1]) ? ((isset($matches[2]) && $matches[2] !== '0') ? $matches[2] : null) : ($matches[1]);
        }

        return null;
    }

    /**
     * Create a DateTime object from various input formats
     *
     * @throws DeserializerException
     */
    private static function createDateTime(mixed $value): \DateTime
    {
        if ($value instanceof \DateTime) {
            return $value;
        }

        if (\is_string($value)) {
            try {
                return new \DateTime($value);
            } catch (\Exception) {
                throw new DeserializerException("Cannot create DateTime from: {$value}");
            }
        }

        if (\is_numeric($value)) {
            return new \DateTime('@'.$value);
        }

        throw new DeserializerException("Cannot create DateTime from value type: ".\gettype($value));
    }

    /**
     * Create a DateTimeImmutable object from various input formats
     *
     * @throws DeserializerException
     */
    private static function createDateTimeImmutable(mixed $value): \DateTimeImmutable
    {
        if ($value instanceof \DateTimeImmutable) {
            return $value;
        }

        if (\is_string($value)) {
            try {
                return new \DateTimeImmutable($value);
            } catch (\Exception) {
                throw new DeserializerException("Cannot create DateTimeImmutable from: {$value}");
            }
        }

        if (\is_numeric($value)) {
            return new \DateTimeImmutable('@'.$value);
        }

        throw new DeserializerException("Cannot create DateTimeImmutable from value type: ".\gettype($value));
    }

    private static function handleEnum(BackedEnum|string $typeName, mixed $value): BackedEnum
    {
        if (!\is_subclass_of($typeName, BackedEnum::class)) {
            throw new DeserializerException("Cannot create BackedEnum from: {$typeName}");
        }

        $enum = $typeName::tryFrom($value);

        if (!$enum instanceof \BackedEnum) {
            throw new DeserializerException("Invalid enum value '{$value}' for {$typeName}");
        }

        return $enum;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

use NeuronAI\StructuredOutput\Validation\Validator;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class ArrayOf extends AbstractValidationRule
{
    protected string $message = '{name} must be an array of {type}';

    private const VALIDATION_FUNCTIONS = [
        'boolean' => 'is_bool',
        'integer' => 'is_int',
        'float' => 'is_float',
        'numeric' => 'is_numeric',
        'string' => 'is_string',
        'scalar' => 'is_scalar',
        'array' => 'is_array',
        'iterable' => 'is_iterable',
        'countable' => 'is_countable',
        'object' => 'is_object',
        'null' => 'is_null',
        'alnum' => 'ctype_alnum',
        'alpha' => 'ctype_alpha',
        'cntrl' => 'ctype_cntrl',
        'digit' => 'ctype_digit',
        'graph' => 'ctype_graph',
        'lower' => 'ctype_lower',
        'print' => 'ctype_print',
        'punct' => 'ctype_punct',
        'space' => 'ctype_space',
        'upper' => 'ctype_upper',
        'xdigit' => 'ctype_xdigit',
    ];

    public function __construct(
        protected string $type,
        protected bool $allowEmpty = false,
    ) {
    }

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if ($this->allowEmpty && empty($value)) {
            return;
        }

        if (!$this->allowEmpty && empty($value)) {
            $violations[] = $this->buildMessage($name, $this->message, ['type' => $this->type]);
            return;
        }

        if (!\is_array($value)) {
            $violations[] = $this->buildMessage($name, $this->message);
            return;
        }

        $type = \strtolower($this->type);

        $error = false;
        foreach ($value as $item) {
            if (isset(self::VALIDATION_FUNCTIONS[$type]) && self::VALIDATION_FUNCTIONS[$type]($item)) {
                continue;
            }

            // It's like a recursive call.
            if ($item instanceof $this->type && Validator::validate($item) === []) {
                continue;
            }

            $error = true;
            break;
        }

        if ($error) {
            $violations[] = $this->buildMessage($name, $this->message, ['type' => $this->type]);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore;

use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use NeuronAI\RAG\Document;

class PineconeVectorStore implements VectorStoreInterface
{
    protected Client $client;

    /**
     * Metadata filters.
     *
     * https://docs.pinecone.io/reference/api/2025-04/data-plane/query#body-filter
     */
    protected array $filters = [];

    public function __construct(
        string $key,
        protected string $indexUrl,
        protected int $topK = 4,
        string $version = '2025-04',
        protected string $namespace = '__default__'
    ) {
        $this->client = new Client([
            'base_uri' => \trim($this->indexUrl, '/').'/',
            'headers' => [
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
                'Api-Key' => $key,
                'X-Pinecone-API-Version' => $version,
            ]
        ]);
    }

    public function addDocument(Document $document): void
    {
        $this->addDocuments([$document]);
    }

    public function addDocuments(array $documents): void
    {
        $this->client->post("vectors/upsert", [
            RequestOptions::JSON => [
                'namespace' => $this->namespace,
                'vectors' => \array_map(fn (Document $document): array => [
                    'id' => $document->getId(),
                    'values' => $document->getEmbedding(),
                    'metadata' => [
                        'content' => $document->getContent(),
                        'sourceType' => $document->getSourceType(),
                        'sourceName' => $document->getSourceName(),
                        ...$document->metadata,
                    ],
                ], $documents)
            ]
        ]);
    }

    public function deleteBySource(string $sourceType, string $sourceName): void
    {
        $this->client->post("vectors/delete", [
            RequestOptions::JSON => [
                'namespace' => $this->namespace,
                'filter' => [
                    'sourceType' => ['$eq' => $sourceType],
                    'sourceName' => ['$eq' => $sourceName],
                ]
            ]
        ]);
    }

    public function similaritySearch(array $embedding): iterable
    {
        $result = $this->client->post("query", [
            RequestOptions::JSON => [
                'namespace' => $this->namespace,
                'includeMetadata' => true,
                'includeValues' => true,
                'vector' => $embedding,
                'topK' => $this->topK,
                'filters' => $this->filters, // Hybrid search
            ]
        ])->getBody()->getContents();

        $result = \json_decode($result, true);

        return \array_map(function (array $item): Document {
            $document = new Document();
            $document->id = $item['id'];
            $document->embedding = $item['values'];
            $document->content = $item['metadata']['content'];
            $document->sourceType = $item['metadata']['sourceType'];
            $document->sourceName = $item['metadata']['sourceName'];
            $document->score = $item['score'];

            foreach ($item['metadata'] as $name => $value) {
                if (!\in_array($name, ['content', 'sourceType', 'sourceName'])) {
                    $document->addMetadata($name, $value);
                }
            }

            return $document;
        }, $result['matches']);
    }

    public function withFilters(array $filters): self
    {
        $this->filters = $filters;
        return $this;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\AWS;

use Aws\Ses\SesClient;
use NeuronAI\Exceptions\ArrayPropertyException;
use NeuronAI\Exceptions\ToolException;
use NeuronAI\Tools\ArrayProperty;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

/**
 * @method static make(SesClient $sesClient, string $fromEmail)
 */
class SESTool extends Tool
{
    public function __construct(
        protected SesClient $sesClient,
        protected string $fromEmail
    ) {
        parent::__construct(
            'send_email',
            <<<DESC
Send an email message to one or more recipients. Use this tool when you need to communicate with users via email,
send notifications, confirmations, reports, or any other email-based communication. The tool handles proper email
formatting, delivery, and basic error handling automatically.
DESC
        );
    }

    /**
     * @throws ArrayPropertyException
     */
    protected function properties(): array
    {
        return [
            new ArrayProperty(
                name: 'to',
                description: 'Array of recipient email addresses. Each email must be a valid email format (e.g., ["user@example.com", "admin@company.com"]). Required - at least one recipient must be specified.',
                required: true,
                minItems: 1,
            ),
            new ToolProperty(
                name: 'subject',
                type: PropertyType::STRING,
                description: 'The email subject line. Should be clear, concise, and descriptive of the email content. This appears in the recipient\'s inbox list.',
                required: true,
            ),
            new ToolProperty(
                name: 'body',
                type: PropertyType::STRING,
                description: 'The main email message content. Can include plain text or HTML formatting. This is the primary message you want to communicate to the recipient(s).',
                required: true,
            ),
            new ArrayProperty(
                name: 'cc',
                description: 'Optional array of email addresses to carbon copy (CC). These recipients will receive the email and all recipients can see these CC addresses.',
            ),
            new ArrayProperty(
                name: 'bcc',
                description: 'Optional array of email addresses to blind carbon copy (BCC). These recipients will receive the email but other recipients cannot see these addresses. Useful for privacy or internal notifications.',
            ),
        ];
    }

    public function __invoke(
        array   $to,
        string  $subject,
        string  $body,
        ?array  $cc = null,
        ?array  $bcc = null,
        ?string $reply_to = null,
    ): array {
        try {
            $this->validateRecipients($to);

            $result = $this->sesClient->sendEmail([
                'Source' => $this->fromEmail,
                'Destination' => $this->buildDestinations($to, $cc, $bcc),
                'Message' => $this->buildMessage($subject, $body),
            ]);

            return [
                'success' => true,
                'message_id' => $result['MessageId'] ?? null,
                'status' => 'sent',
                'recipients_count' => \count($to),
                'aws_request_id' => $result['@metadata']['requestId'] ?? null
            ];
        } catch (\Exception $e) {
            return [
                'success' => false,
                'error' => $e->getMessage(),
                'error_type' => $e::class,
                'status' => 'failed'
            ];
        }
    }

    /**
     * @param array<string> $to
     * @throws ToolException
     */
    protected function validateRecipients(array $to): void
    {
        foreach ($to as $recipient) {
            if (\filter_var($recipient, \FILTER_VALIDATE_EMAIL) === false) {
                throw new ToolException('Invalid email address: ' . $recipient . '.');
            }
        }
    }

    protected function buildDestinations(array $to, ?array $cc = null, ?array $bcc = null): array
    {
        $destinations = [
            'ToAddresses' => $to,
        ];

        if ($cc !== null && $cc !== []) {
            $destinations['CcAddresses'] = $cc;
        }

        if ($bcc !== null && $bcc !== []) {
            $destinations['BccAddresses'] = $bcc;
        }

        return $destinations;
    }

    protected function buildMessage(string $subject, string $body): array
    {
        return [
            'Subject' => [
                'Data' => $subject,
                'Charset' => 'UTF-8'
            ],
            'Body' => [
                'Html' => [
                    'Data' => $body,
                    'Charset' => 'UTF-8'
                ],
                'Text' => [
                    'Data' => \strip_tags($body),
                    'Charset' => 'UTF-8'
                ]
            ]
        ];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\PGSQL;

use NeuronAI\Tools\Tool;
use PDO;

/**
 * @method static static make(PDO $pdo, ?array $tables = null)
 */
class PGSQLSchemaTool extends Tool
{
    public function __construct(
        protected PDO $pdo,
        protected ?array $tables = null,
    ) {
        parent::__construct(
            'analyze_postgresql_database_schema',
            'Retrieves PostgreSQL database schema information including tables, columns, relationships, and indexes.
Use this tool first to understand the database structure before writing any SQL queries.
Essential for generating accurate queries with proper table/column names, JOIN conditions,
and performance optimization. If you already know the database structure, you can skip this step.'
        );
    }

    public function __invoke(): string
    {
        return $this->formatForLLM([
            'tables' => $this->getTables(),
            'relationships' => $this->getRelationships(),
            'indexes' => $this->getIndexes(),
            'constraints' => $this->getConstraints()
        ]);
    }

    private function getTables(): array
    {
        $whereClause = "WHERE t.table_schema = current_schema() AND t.table_type = 'BASE TABLE'";
        $params = [];

        if ($this->tables !== null && $this->tables !== []) {
            $placeholders = [];
            foreach ($this->tables as $table) {
                $placeholders[] = '?';
                $params[] = $table;
            }
            $whereClause .= " AND t.table_name = ANY(ARRAY[" . \implode(',', $placeholders) . "])";
        }

        $stmt = $this->pdo->prepare("
            SELECT
                t.table_name,
                obj_description(pgc.oid) as table_comment,
                c.column_name,
                c.ordinal_position,
                c.column_default,
                c.is_nullable,
                c.data_type,
                c.character_maximum_length,
                c.numeric_precision,
                c.numeric_scale,
                c.udt_name,
                CASE
                    WHEN pk.column_name IS NOT NULL THEN 'PRI'
                    WHEN uk.column_name IS NOT NULL THEN 'UNI'
                    WHEN idx.column_name IS NOT NULL THEN 'MUL'
                    ELSE ''
                END as column_key,
                CASE
                    WHEN c.column_default LIKE 'nextval%' THEN 'auto_increment'
                    ELSE ''
                END as extra,
                col_description(pgc.oid, c.ordinal_position) as column_comment
            FROM information_schema.tables t
            LEFT JOIN information_schema.columns c ON t.table_name = c.table_name
                AND t.table_schema = c.table_schema
            LEFT JOIN pg_class pgc ON pgc.relname = t.table_name AND pgc.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())
            LEFT JOIN (
                SELECT ku.table_name, ku.column_name
                FROM information_schema.table_constraints tc
                JOIN information_schema.key_column_usage ku ON tc.constraint_name = ku.constraint_name
                WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = current_schema()
            ) pk ON pk.table_name = c.table_name AND pk.column_name = c.column_name
            LEFT JOIN (
                SELECT ku.table_name, ku.column_name
                FROM information_schema.table_constraints tc
                JOIN information_schema.key_column_usage ku ON tc.constraint_name = ku.constraint_name
                WHERE tc.constraint_type = 'UNIQUE' AND tc.table_schema = current_schema()
            ) uk ON uk.table_name = c.table_name AND uk.column_name = c.column_name
            LEFT JOIN (
                SELECT
                    t.relname as table_name,
                    a.attname as column_name
                FROM pg_index i
                JOIN pg_class t ON t.oid = i.indrelid
                JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(i.indkey)
                JOIN pg_namespace n ON n.oid = t.relnamespace
                WHERE i.indisprimary = false AND i.indisunique = false AND n.nspname = current_schema()
            ) idx ON idx.table_name = c.table_name AND idx.column_name = c.column_name
            $whereClause
            ORDER BY t.table_name, c.ordinal_position
        ");

        $stmt->execute($params);
        $results = $stmt->fetchAll(PDO::FETCH_ASSOC);

        $tables = [];
        foreach ($results as $row) {
            $tableName = $row['table_name'];

            if (!isset($tables[$tableName])) {
                $tables[$tableName] = [
                    'name' => $tableName,
                    'engine' => 'PostgreSQL',
                    'estimated_rows' => $this->getTableRowCount($tableName),
                    'comment' => $row['table_comment'],
                    'columns' => [],
                    'primary_key' => [],
                    'unique_keys' => [],
                    'indexes' => []
                ];
            }

            if ($row['column_name']) {
                // Map PostgreSQL types to a more readable format
                $fullType = $this->formatPostgreSQLType($row);

                $column = [
                    'name' => $row['column_name'],
                    'type' => $row['data_type'],
                    'full_type' => $fullType,
                    'nullable' => $row['is_nullable'] === 'YES',
                    'default' => $row['column_default'],
                    'auto_increment' => \str_contains((string) $row['extra'], 'auto_increment'),
                    'comment' => $row['column_comment']
                ];

                if ($row['character_maximum_length']) {
                    $column['max_length'] = $row['character_maximum_length'];
                }
                if ($row['numeric_precision']) {
                    $column['precision'] = $row['numeric_precision'];
                    $column['scale'] = $row['numeric_scale'];
                }

                $tables[$tableName]['columns'][] = $column;

                if ($row['column_key'] === 'PRI') {
                    $tables[$tableName]['primary_key'][] = $row['column_name'];
                } elseif ($row['column_key'] === 'UNI') {
                    $tables[$tableName]['unique_keys'][] = $row['column_name'];
                } elseif ($row['column_key'] === 'MUL') {
                    $tables[$tableName]['indexes'][] = $row['column_name'];
                }
            }
        }

        return $tables;
    }

    private function getTableRowCount(string $tableName): string
    {
        try {
            $stmt = $this->pdo->prepare("
                SELECT n_tup_ins - n_tup_del as estimate
                FROM pg_stat_user_tables
                WHERE relname = $1
            ");
            $stmt->execute([$tableName]);
            $result = $stmt->fetchColumn();
            return $result !== false ? (string)$result : 'N/A';
        } catch (\Exception) {
            return 'N/A';
        }
    }

    private function formatPostgreSQLType(array $row): string
    {
        $type = $row['udt_name'] ?? $row['data_type'];

        // Handle specific PostgreSQL types
        if ($type === 'varchar' || $type === 'character varying') {
            $type = 'character varying';
        }
        if ($row['character_maximum_length']) {
            return "{$type}({$row['character_maximum_length']})";
        }
        if ($row['numeric_precision'] && $row['numeric_scale']) {
            return "{$type}({$row['numeric_precision']},{$row['numeric_scale']})";
        }

        if ($row['numeric_precision']) {
            return "{$type}({$row['numeric_precision']})";
        }

        return $type;
    }

    private function getRelationships(): array
    {
        $whereClause = "WHERE tc.table_schema = current_schema()";
        $paramIndex = 1;
        $params = [];

        if ($this->tables !== null && $this->tables !== []) {
            $placeholders = [];
            foreach ($this->tables as $table) {
                $placeholders[] = '?';
                $params[] = $table;
            }
            $additionalPlaceholders = [];
            foreach ($this->tables as $table) {
                $additionalPlaceholders[] = '$' . $paramIndex++;
                $params[] = $table;
            }
            $whereClause .= " AND (tc.table_name = ANY(ARRAY[" . \implode(',', $placeholders) . "]) OR ccu.table_name = ANY(ARRAY[" . \implode(',', $additionalPlaceholders) . "]))";
        }

        $stmt = $this->pdo->prepare("
            SELECT
                tc.constraint_name,
                tc.table_name as source_table,
                kcu.column_name as source_column,
                ccu.table_name as target_table,
                ccu.column_name as target_column,
                rc.update_rule,
                rc.delete_rule
            FROM information_schema.table_constraints tc
            JOIN information_schema.key_column_usage kcu
                ON tc.constraint_name = kcu.constraint_name
                AND tc.table_schema = kcu.table_schema
            JOIN information_schema.constraint_column_usage ccu
                ON ccu.constraint_name = tc.constraint_name
                AND ccu.table_schema = tc.table_schema
            JOIN information_schema.referential_constraints rc
                ON tc.constraint_name = rc.constraint_name
                AND tc.table_schema = rc.constraint_schema
            $whereClause
            AND tc.constraint_type = 'FOREIGN KEY'
            ORDER BY tc.table_name
        ");

        $stmt->execute($params);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    private function getIndexes(): array
    {
        $whereClause = "WHERE schemaname = current_schema() AND indexname NOT LIKE '%_pkey'";
        $params = [];

        if ($this->tables !== null && $this->tables !== []) {
            $placeholders = [];
            foreach ($this->tables as $table) {
                $placeholders[] = '?';
                $params[] = $table;
            }
            $whereClause .= " AND tablename = ANY(ARRAY[" . \implode(',', $placeholders) . "])";
        }

        $stmt = $this->pdo->prepare("
            SELECT
                schemaname,
                tablename,
                indexname,
                indexdef
            FROM pg_indexes
            $whereClause
            ORDER BY tablename, indexname
        ");

        $stmt->execute($params);
        $results = $stmt->fetchAll(PDO::FETCH_ASSOC);

        $indexes = [];
        foreach ($results as $row) {
            // Parse column names from index definition
            \preg_match('/\((.*?)\)/', (string) $row['indexdef'], $matches);
            $columnList = $matches[1] ?? '';
            $columns = \array_map('trim', \explode(',', $columnList));

            // Clean up column names (remove function calls, etc.)
            $cleanColumns = [];
            foreach ($columns as $col) {
                // Extract just the column name if it's wrapped in functions
                if (\preg_match('/([a-zA-Z_]\w*)/', $col, $colMatches)) {
                    $cleanColumns[] = $colMatches[1];
                }
            }

            $indexes[] = [
                'table' => $row['tablename'],
                'name' => $row['indexname'],
                'unique' => \str_contains((string) $row['indexdef'], 'UNIQUE'),
                'type' => $this->extractIndexType($row['indexdef']),
                'columns' => $cleanColumns === [] ? $columns : $cleanColumns
            ];
        }

        return $indexes;
    }

    private function extractIndexType(string $indexDef): string
    {
        if (\str_contains($indexDef, 'USING gin')) {
            return 'GIN';
        }
        if (\str_contains($indexDef, 'USING gist')) {
            return 'GIST';
        }
        if (\str_contains($indexDef, 'USING hash')) {
            return 'HASH';
        }
        if (\str_contains($indexDef, 'USING brin')) {
            return 'BRIN';
        }
        return 'BTREE';
        // Default

    }

    private function getConstraints(): array
    {
        $whereClause = "WHERE table_schema = current_schema() AND constraint_type IN ('UNIQUE', 'CHECK')";
        $params = [];

        if ($this->tables !== null && $this->tables !== []) {
            $placeholders = [];
            foreach ($this->tables as $table) {
                $placeholders[] = '?';
                $params[] = $table;
            }
            $whereClause .= " AND table_name = ANY(ARRAY[" . \implode(',', $placeholders) . "])";
        }

        $stmt = $this->pdo->prepare("
            SELECT
                constraint_name,
                table_name,
                constraint_type
            FROM information_schema.table_constraints
            $whereClause
        ");

        $stmt->execute($params);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    private function formatForLLM(array $structure): string
    {
        $output = "# PostgreSQL Database Schema Analysis\n\n";
        $output .= "This PostgreSQL database contains " . \count($structure['tables']) . " tables with the following structure:\n\n";

        // Tables overview
        $output .= "## Tables Overview\n";
        $tableCount = \count($structure['tables']);
        $filteredNote = $this->tables !== null ? " (filtered to specified tables)" : "";
        $output .= "Analyzing {$tableCount} tables{$filteredNote}:\n";

        foreach ($structure['tables'] as $table) {
            $pkColumns = empty($table['primary_key']) ? 'None' : \implode(', ', $table['primary_key']);
            $output .= "- **{$table['name']}**: {$table['estimated_rows']} rows, Primary Key: {$pkColumns}";
            if ($table['comment']) {
                $output .= " - {$table['comment']}";
            }
            $output .= "\n";
        }
        $output .= "\n";

        // Detailed table structures
        $output .= "## Detailed Table Structures\n\n";
        foreach ($structure['tables'] as $table) {
            $output .= "### Table: `{$table['name']}`\n";
            if ($table['comment']) {
                $output .= "**Description**: {$table['comment']}\n";
            }
            $output .= "**Estimated Rows**: {$table['estimated_rows']}\n\n";

            $output .= "**Columns**:\n";
            foreach ($table['columns'] as $column) {
                $nullable = $column['nullable'] ? 'NULL' : 'NOT NULL';
                $autoInc = $column['auto_increment'] ? ' (SERIAL/SEQUENCE)' : '';
                $default = $column['default'] !== null ? " DEFAULT {$column['default']}" : '';

                $output .= "- `{$column['name']}` {$column['full_type']} {$nullable}{$default}{$autoInc}";
                if ($column['comment']) {
                    $output .= " - {$column['comment']}";
                }
                $output .= "\n";
            }

            if (!empty($table['primary_key'])) {
                $output .= "\n**Primary Key**: " . \implode(', ', $table['primary_key']) . "\n";
            }

            if (!empty($table['unique_keys'])) {
                $output .= "**Unique Keys**: " . \implode(', ', $table['unique_keys']) . "\n";
            }

            $output .= "\n";
        }

        // Relationships
        if (!empty($structure['relationships'])) {
            $output .= "## Foreign Key Relationships\n\n";
            $output .= "Understanding these relationships is crucial for JOIN operations:\n\n";

            foreach ($structure['relationships'] as $rel) {
                $output .= "- `{$rel['source_table']}.{$rel['source_column']}` → `{$rel['target_table']}.{$rel['target_column']}`";
                $output .= " (ON DELETE {$rel['delete_rule']}, ON UPDATE {$rel['update_rule']})\n";
            }
            $output .= "\n";
        }

        // Indexes for query optimization
        if (!empty($structure['indexes'])) {
            $output .= "## Available Indexes (for Query Optimization)\n\n";
            $output .= "These indexes can significantly improve query performance:\n\n";

            foreach ($structure['indexes'] as $index) {
                $unique = $index['unique'] ? 'UNIQUE ' : '';
                $columns = \implode(', ', $index['columns']);
                $output .= "- {$unique}{$index['type']} INDEX `{$index['name']}` on `{$index['table']}` ({$columns})\n";
            }
            $output .= "\n";
        }

        // PostgreSQL-specific query guidelines
        $output .= "## PostgreSQL SQL Query Generation Guidelines\n\n";
        $output .= "**Best Practices for this PostgreSQL database**:\n";
        $output .= "1. Always use table aliases for better readability\n";
        $output .= "2. Prefer indexed columns in WHERE clauses for better performance\n";
        $output .= "3. Use appropriate JOINs based on the foreign key relationships listed above\n";
        $output .= "4. Use double quotes (\") for identifiers if they contain special characters or are case-sensitive\n";
        $output .= "5. PostgreSQL is case-sensitive for identifiers - use exact casing as shown above\n";
        $output .= "6. Use \$1, \$2, etc. for parameterized queries in prepared statements\n";
        $output .= "7. LIMIT clause syntax: `SELECT ... LIMIT n OFFSET m`\n";
        $output .= "8. String comparisons are case-sensitive by default (use ILIKE for case-insensitive)\n";
        $output .= "9. Use single quotes (') for string literals, not double quotes\n";
        $output .= "10. PostgreSQL supports advanced features like arrays, JSON/JSONB, and full-text search\n";
        $output .= "11. Use RETURNING clause for INSERT/UPDATE/DELETE to get back modified data\n\n";

        // Common patterns
        $output .= "**Common PostgreSQL Query Patterns**:\n";
        $this->addCommonPatterns($output, $structure['tables']);

        return $output;
    }

    private function addCommonPatterns(string &$output, array $tables): void
    {
        // Find tables with timestamps for temporal queries
        foreach ($tables as $table) {
            foreach ($table['columns'] as $column) {
                if (\in_array($column['type'], ['timestamp without time zone', 'timestamp with time zone', 'timestamptz', 'date', 'time']) &&
                    (\str_contains(\strtolower((string) $column['name']), 'created') ||
                     \str_contains(\strtolower((string) $column['name']), 'updated'))) {
                    $output .= "- For temporal queries on `{$table['name']}`, use `{$column['name']}` column\n";
                    break;
                }
            }
        }

        // Find potential text search columns
        foreach ($tables as $table) {
            foreach ($table['columns'] as $column) {
                if (\in_array($column['type'], ['character varying', 'varchar', 'text', 'character']) &&
                    (\str_contains(\strtolower((string) $column['name']), 'name') ||
                     \str_contains(\strtolower((string) $column['name']), 'title') ||
                     \str_contains(\strtolower((string) $column['name']), 'description'))) {
                    $output .= "- For text searches on `{$table['name']}`, consider using `{$column['name']}` with ILIKE, ~ (regex), or full-text search\n";
                    break;
                }
            }
        }

        // Find JSON/JSONB columns
        foreach ($tables as $table) {
            foreach ($table['columns'] as $column) {
                if (\in_array($column['type'], ['json', 'jsonb'])) {
                    $output .= "- Table `{$table['name']}` has {$column['type']} column `{$column['name']}` - use JSON operators like ->, ->>, @>, ? for querying\n";
                }
            }
        }

        // Find array columns
        foreach ($tables as $table) {
            foreach ($table['columns'] as $column) {
                if (\str_contains((string) $column['full_type'], '[]')) {
                    $output .= "- Table `{$table['name']}` has array column `{$column['name']}` ({$column['full_type']}) - use array operators like ANY, ALL, @>\n";
                }
            }
        }

        // Find UUID columns
        foreach ($tables as $table) {
            foreach ($table['columns'] as $column) {
                if ($column['type'] === 'uuid') {
                    $output .= "- Table `{$table['name']}` uses UUID for `{$column['name']}` - use gen_random_uuid() for generating new UUIDs\n";
                    break;
                }
            }
        }

        $output .= "\n";
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability;

use NeuronAI\AgentInterface;
use NeuronAI\Observability\Events\ToolCalled;
use NeuronAI\Observability\Events\ToolCalling;
use NeuronAI\Observability\Events\ToolsBootstrapped;
use NeuronAI\Tools\ToolInterface;

trait HandleToolEvents
{
    public function toolsBootstrapping(AgentInterface $agent, string $event, mixed $data): void
    {
        if (!$this->inspector->canAddSegments()) {
            return;
        }

        $this->segments[$agent::class.'_tools_bootstrap'] = $this->inspector
            ->startSegment(
                self::SEGMENT_TYPE.'-tools',
                "tools_bootstrap()"
            )
            ->setColor(self::SEGMENT_COLOR);
    }

    public function toolsBootstrapped(AgentInterface $agent, string $event, ToolsBootstrapped $data): void
    {
        if (\array_key_exists($agent::class.'_tools_bootstrap', $this->segments) && $data->tools !== []) {
            $segment = $this->segments[$agent::class.'_tools_bootstrap']->end();
            $segment->addContext('Tools', \array_reduce($data->tools, function (array $carry, ToolInterface $tool): array {
                $carry[$tool->getName()] = $tool->getDescription();
                return $carry;
            }, []));
            $segment->addContext('Guidelines', $data->guidelines);
        }
    }

    public function toolCalling(AgentInterface $agent, string $event, ToolCalling $data): void
    {
        if (!$this->inspector->canAddSegments()) {
            return;
        }

        $this->segments[$data->tool->getName()] = $this->inspector
            ->startSegment(
                self::SEGMENT_TYPE.'-tools',
                "tool_call( {$data->tool->getName()} )"
            )
            ->setColor(self::SEGMENT_COLOR);
    }

    public function toolCalled(AgentInterface $agent, string $event, ToolCalled $data): void
    {
        if (\array_key_exists($data->tool->getName(), $this->segments)) {
            $this->segments[$data->tool->getName()]
                ->end()
                ->addContext('Properties', $data->tool->getProperties())
                ->addContext('Inputs', $data->tool->getInputs())
                ->addContext('Output', $data->tool->getResult());
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore;

use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use NeuronAI\RAG\Document;

class ChromaVectorStore implements VectorStoreInterface
{
    protected Client $client;

    public function __construct(
        protected string $collection,
        protected string $host = 'http://localhost:8000',
        protected int $topK = 5,
    ) {
    }

    protected function getClient(): Client
    {
        return $this->client ?? $this->client = new Client([
            'base_uri' => \trim($this->host, '/')."/api/v1/collections/{$this->collection}/",
            'headers' => [
                'Content-Type' => 'application/json',
            ]
        ]);
    }

    public function addDocument(Document $document): void
    {
        $this->addDocuments([$document]);
    }

    public function deleteBySource(string $sourceType, string $sourceName): void
    {
        $this->getClient()->post('delete', [
            RequestOptions::JSON => [
                'where' => [
                    'sourceType' => $sourceType,
                    'sourceName' => $sourceName,
                ]
            ]
        ]);
    }

    public function addDocuments(array $documents): void
    {
        $this->getClient()->post('upsert', [
            RequestOptions::JSON => $this->mapDocuments($documents),
        ])->getBody()->getContents();
    }

    public function similaritySearch(array $embedding): iterable
    {
        $response = $this->getClient()->post('query', [
            RequestOptions::JSON => [
                'queryEmbeddings' => $embedding,
                'nResults' => $this->topK,
            ]
        ])->getBody()->getContents();

        $response = \json_decode($response, true);

        // Map the result
        $size = \count($response['distances']);
        $result = [];
        for ($i = 0; $i < $size; $i++) {
            $document = new Document();
            $document->id = $response['ids'][$i] ?? \uniqid();
            $document->embedding = $response['embeddings'][$i];
            $document->content = $response['documents'][$i];
            $document->sourceType = $response['metadatas'][$i]['sourceType'] ?? null;
            $document->sourceName = $response['metadatas'][$i]['sourceName'] ?? null;
            $document->score = VectorSimilarity::similarityFromDistance($response['distances'][$i]);

            foreach ($response['metadatas'][$i] as $name => $value) {
                if (!\in_array($name, ['content', 'sourceType', 'sourceName', 'score', 'embedding', 'id'])) {
                    $document->addMetadata($name, $value);
                }
            }

            $result[] = $document;
        }

        return $result;
    }

    /**
     * @param Document[] $documents
     */
    protected function mapDocuments(array $documents): array
    {
        $payload = [
            'ids' => [],
            'documents' => [],
            'embeddings' => [],
            'metadatas' => [],
        ];

        foreach ($documents as $document) {
            $payload['ids'][] = $document->getId();
            $payload['documents'][] = $document->getContent();
            $payload['embeddings'][] = $document->getEmbedding();
            $payload['metadatas'][] = [
                'sourceType' => $document->getSourceType(),
                'sourceName' => $document->getSourceName(),
                ...$document->metadata,
            ];
        }

        return $payload;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore;

use Elastic\Elasticsearch\Exception\ClientResponseException;
use Elastic\Elasticsearch\Exception\ServerResponseException;
use NeuronAI\RAG\Document;
use Elastic\Elasticsearch\Client;
use Elastic\Elasticsearch\Response\Elasticsearch;

class ElasticsearchVectorStore implements VectorStoreInterface
{
    protected bool $vectorDimSet = false;

    protected array $filters = [];

    public function __construct(
        protected Client $client,
        protected string $index,
        protected int $topK = 4,
    ) {
    }

    protected function checkIndexStatus(Document $document): void
    {
        /** @var Elasticsearch $existResponse */
        $existResponse = $this->client->indices()->exists(['index' => $this->index]);
        $existStatusCode = $existResponse->getStatusCode();

        if ($existStatusCode === 200) {
            // Map vector embeddings dimension on the fly adding a document.
            $this->mapVectorDimension(\count($document->getEmbedding()));
            return;
        }

        $properties = [
            'content' => [
                'type' => 'text',
            ],
            'sourceType' => [
                'type' => 'keyword',
            ],
            'sourceName' => [
                'type' => 'keyword',
            ]
        ];

        // Map metadata
        foreach (\array_keys($document->metadata) as $name) {
            $properties[$name] = [
                'type' => 'keyword',
            ];
        }

        $this->client->indices()->create([
            'index' => $this->index,
            'body' => [
                'mappings' => [
                    'properties' => $properties,
                ],
            ],
        ]);
    }

    /**
     * @throws \Exception
     */
    public function addDocument(Document $document): void
    {
        if ($document->embedding === []) {
            throw new \Exception('Document embedding must be set before adding a document');
        }

        $this->checkIndexStatus($document);

        $this->client->index([
            'index' => $this->index,
            'body' => [
                'embedding' => $document->getEmbedding(),
                'content' => $document->getContent(),
                'sourceType' => $document->getSourceType(),
                'sourceName' => $document->getSourceName(),
                ...$document->metadata,
            ],
        ]);

        $this->client->indices()->refresh(['index' => $this->index]);
    }

    /**
     * @param  Document[]  $documents
     *
     * @throws \Exception
     */
    public function addDocuments(array $documents): void
    {
        if ($documents === []) {
            return;
        }

        if (empty($documents[0]->getEmbedding())) {
            throw new \Exception('Document embedding must be set before adding a document');
        }

        $this->checkIndexStatus($documents[0]);

        /*
         * Generate a bulk payload
         */
        $params = ['body' => []];
        foreach ($documents as $document) {
            $params['body'][] = [
                'index' => [
                    '_index' => $this->index,
                ],
            ];
            $params['body'][] = [
                'embedding' => $document->getEmbedding(),
                'content' => $document->getContent(),
                'sourceType' => $document->getSourceType(),
                'sourceName' => $document->getSourceName(),
                ...$document->metadata,
            ];
        }
        $this->client->bulk($params);
        $this->client->indices()->refresh(['index' => $this->index]);
    }

    public function deleteBySource(string $sourceType, string $sourceName): void
    {
        $this->client->deleteByQuery([
            'index' => $this->index,
            'q' => "sourceType:{$sourceType} AND sourceName:{$sourceName}",
            'body' => []
        ]);
        $this->client->indices()->refresh(['index' => $this->index]);
    }

    /**
     * {@inheritDoc}
     *
     * num_candidates are used to tune approximate kNN for speed or accuracy (see : https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html#tune-approximate-knn-for-speed-accuracy)
     * @return Document[]
     * @throws ClientResponseException
     * @throws ServerResponseException
     */
    public function similaritySearch(array $embedding): array
    {
        $searchParams = [
            'index' => $this->index,
            'body' => [
                'knn' => [
                    'field' => 'embedding',
                    'query_vector' => $embedding,
                    'k' => $this->topK,
                    'num_candidates' => \max(50, $this->topK * 4),
                ],
                'sort' => [
                    '_score' => [
                        'order' => 'desc',
                    ],
                ],
            ],
        ];

        // Hybrid search
        if ($this->filters !== []) {
            $searchParams['body']['knn']['filter'] = $this->filters;
        }

        $response = $this->client->search($searchParams);

        return \array_map(function (array $item): Document {
            $document = new Document($item['_source']['content']);
            //$document->embedding = $item['_source']['embedding']; // avoid carrying large data
            $document->sourceType = $item['_source']['sourceType'];
            $document->sourceName = $item['_source']['sourceName'];
            $document->score = $item['_score'];

            foreach ($item['_source'] as $name => $value) {
                if (!\in_array($name, ['content', 'sourceType', 'sourceName', 'score', 'embedding', 'id'])) {
                    $document->addMetadata($name, $value);
                }
            }

            return $document;
        }, $response['hits']['hits']);
    }

    /**
     * Map vector embeddings dimension on the fly.
     */
    private function mapVectorDimension(int $dimension): void
    {
        if ($this->vectorDimSet) {
            return;
        }

        $response = $this->client->indices()->getFieldMapping([
            'index' => $this->index,
            'fields' => 'embedding',
        ]);

        $mappings = $response[$this->index]['mappings'];
        if (
            \array_key_exists('embedding', $mappings)
            && $mappings['embedding']['mapping']['embedding']['dims'] === $dimension
        ) {
            return;
        }

        $this->client->indices()->putMapping([
            'index' => $this->index,
            'body' => [
                'properties' => [
                    'embedding' => [
                        'type' => 'dense_vector',
                        //'element_type' => 'float', // it's float by default
                        'dims' => $dimension,
                        'index' => true,
                        'similarity' => 'cosine',
                    ],
                ],
            ],
        ]);

        $this->vectorDimSet = true;
    }

    public function withFilters(array $filters): self
    {
        $this->filters = $filters;
        return $this;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\MySQL;

use NeuronAI\Tools\Tool;
use PDO;

/**
 * @method static static make(PDO $pdo, ?array $tables = null)
 */
class MySQLSchemaTool extends Tool
{
    public function __construct(
        protected PDO $pdo,
        protected ?array $tables = null,
    ) {
        parent::__construct(
            'analyze_mysql_database_schema',
            'Retrieves MySQL database schema information including tables, columns, relationships, and indexes.
            Use this tool first to understand the database structure before writing any SQL queries.
            Essential for generating accurate queries with proper table/column names, JOIN conditions,
            and performance optimization. If you already know the database structure, you can skip this step.'
        );
    }

    public function __invoke(): string
    {
        return $this->formatForLLM([
            'tables' => $this->getTables(),
            'relationships' => $this->getRelationships(),
            'indexes' => $this->getIndexes(),
            'constraints' => $this->getConstraints()
        ]);
    }

    protected function formatForLLM(array $structure): string
    {
        $output = "# MySQL Database Schema Analysis\n\n";
        $output .= "This database contains " . \count($structure['tables']) . " tables with the following structure:\n\n";

        // Tables overview
        $output .= "## Tables Overview\n";
        foreach ($structure['tables'] as $table) {
            $pkColumns = empty($table['primary_key']) ? 'None' : \implode(', ', $table['primary_key']);
            $output .= "- **{$table['name']}**: {$table['estimated_rows']} rows, Primary Key: {$pkColumns}";
            if ($table['comment']) {
                $output .= " - {$table['comment']}";
            }
            $output .= "\n";
        }
        $output .= "\n";

        // Detailed table structures
        $output .= "## Detailed Table Structures\n\n";
        foreach ($structure['tables'] as $table) {
            $output .= "### Table: `{$table['name']}`\n";
            if ($table['comment']) {
                $output .= "**Description**: {$table['comment']}\n";
            }
            $output .= "**Estimated Rows**: {$table['estimated_rows']}\n\n";

            $output .= "**Columns**:\n";
            foreach ($table['columns'] as $column) {
                $nullable = $column['nullable'] ? 'NULL' : 'NOT NULL';
                $autoInc = $column['auto_increment'] ? ' AUTO_INCREMENT' : '';
                $default = $column['default'] !== null ? " DEFAULT '{$column['default']}'" : '';

                $output .= "- `{$column['name']}` {$column['full_type']} {$nullable}{$default}{$autoInc}";
                if ($column['comment']) {
                    $output .= " - {$column['comment']}";
                }
                $output .= "\n";
            }

            if (!empty($table['primary_key'])) {
                $output .= "\n**Primary Key**: " . \implode(', ', $table['primary_key']) . "\n";
            }

            if (!empty($table['unique_keys'])) {
                $output .= "**Unique Keys**: " . \implode(', ', $table['unique_keys']) . "\n";
            }

            $output .= "\n";
        }

        // Relationships
        if (!empty($structure['relationships'])) {
            $output .= "## Foreign Key Relationships\n\n";
            $output .= "Understanding these relationships is crucial for JOIN operations:\n\n";

            foreach ($structure['relationships'] as $rel) {
                $output .= "- `{$rel['source_table']}.{$rel['source_column']}` → `{$rel['target_table']}.{$rel['target_column']}`";
                $output .= " (ON DELETE {$rel['DELETE_RULE']}, ON UPDATE {$rel['UPDATE_RULE']})\n";
            }
            $output .= "\n";
        }

        // Indexes for query optimization
        if (!empty($structure['indexes'])) {
            $output .= "## Available Indexes (for Query Optimization)\n\n";
            $output .= "These indexes can significantly improve query performance:\n\n";

            foreach ($structure['indexes'] as $index) {
                $unique = $index['unique'] ? 'UNIQUE ' : '';
                $columns = \implode(', ', $index['columns']);
                $output .= "- {$unique}INDEX `{$index['name']}` on `{$index['table']}` ({$columns})\n";
            }
            $output .= "\n";
        }

        // Query generation guidelines
        $output .= "## MySQL Query Generation Guidelines\n\n";
        $output .= "**Best Practices for this database**:\n";
        $output .= "1. Always use table aliases for better readability\n";
        $output .= "2. Prefer indexed columns in WHERE clauses for better performance\n";
        $output .= "3. Use appropriate JOINs based on the foreign key relationships listed above\n";
        $output .= "4. Consider the estimated row counts when writing queries - larger tables may need LIMIT clauses\n";
        $output .= "5. Pay attention to nullable columns when using comparison operators\n\n";

        // Common patterns
        $output .= "**Common Query Patterns**:\n";
        $this->addCommonPatterns($output, $structure['tables']);

        return $output;
    }

    protected function getTables(): array
    {
        $whereClause = "WHERE t.TABLE_SCHEMA = DATABASE() AND t.TABLE_TYPE = 'BASE TABLE'";
        $params = [];

        // Add table filtering if specific tables are requested
        if ($this->tables !== null && $this->tables !== []) {
            $placeholders = \str_repeat('?,', \count($this->tables) - 1) . '?';
            $whereClause .= " AND t.TABLE_NAME IN ($placeholders)";
            $params = $this->tables;
        }

        $stmt = $this->pdo->prepare("
            SELECT
                t.TABLE_NAME,
                t.ENGINE,
                t.TABLE_ROWS,
                t.TABLE_COMMENT,
                c.COLUMN_NAME,
                c.ORDINAL_POSITION,
                c.COLUMN_DEFAULT,
                c.IS_NULLABLE,
                c.DATA_TYPE,
                c.CHARACTER_MAXIMUM_LENGTH,
                c.NUMERIC_PRECISION,
                c.NUMERIC_SCALE,
                c.COLUMN_TYPE,
                c.COLUMN_KEY,
                c.EXTRA,
                c.COLUMN_COMMENT
            FROM INFORMATION_SCHEMA.TABLES t
            LEFT JOIN INFORMATION_SCHEMA.COLUMNS c ON t.TABLE_NAME = c.TABLE_NAME
                AND t.TABLE_SCHEMA = c.TABLE_SCHEMA
            $whereClause
            ORDER BY t.TABLE_NAME, c.ORDINAL_POSITION
        ");

        $stmt->execute($params);
        $results = $stmt->fetchAll(PDO::FETCH_ASSOC);

        $tables = [];
        foreach ($results as $row) {
            $tableName = $row['TABLE_NAME'];

            if (!isset($tables[$tableName])) {
                $tables[$tableName] = [
                    'name' => $tableName,
                    'engine' => $row['ENGINE'],
                    'estimated_rows' => $row['TABLE_ROWS'],
                    'comment' => $row['TABLE_COMMENT'],
                    'columns' => [],
                    'primary_key' => [],
                    'unique_keys' => [],
                    'indexes' => []
                ];
            }

            if ($row['COLUMN_NAME']) {
                $column = [
                    'name' => $row['COLUMN_NAME'],
                    'type' => $row['DATA_TYPE'],
                    'full_type' => $row['COLUMN_TYPE'],
                    'nullable' => $row['IS_NULLABLE'] === 'YES',
                    'default' => $row['COLUMN_DEFAULT'],
                    'auto_increment' => \str_contains((string) $row['EXTRA'], 'auto_increment'),
                    'comment' => $row['COLUMN_COMMENT']
                ];

                // Add length/precision info for better LLM understanding
                if ($row['CHARACTER_MAXIMUM_LENGTH']) {
                    $column['max_length'] = $row['CHARACTER_MAXIMUM_LENGTH'];
                }
                if ($row['NUMERIC_PRECISION']) {
                    $column['precision'] = $row['NUMERIC_PRECISION'];
                    $column['scale'] = $row['NUMERIC_SCALE'];
                }

                $tables[$tableName]['columns'][] = $column;

                // Track key information
                if ($row['COLUMN_KEY'] === 'PRI') {
                    $tables[$tableName]['primary_key'][] = $row['COLUMN_NAME'];
                } elseif ($row['COLUMN_KEY'] === 'UNI') {
                    $tables[$tableName]['unique_keys'][] = $row['COLUMN_NAME'];
                } elseif ($row['COLUMN_KEY'] === 'MUL') {
                    $tables[$tableName]['indexes'][] = $row['COLUMN_NAME'];
                }
            }
        }

        return $tables;
    }

    protected function getRelationships(): array
    {
        $whereClause = "WHERE kcu.TABLE_SCHEMA = DATABASE() AND kcu.REFERENCED_TABLE_NAME IS NOT NULL";
        $params = [];

        // Add table filtering if specific tables are requested
        if ($this->tables !== null && $this->tables !== []) {
            $placeholders = \str_repeat('?,', \count($this->tables) - 1) . '?';
            $whereClause .= " AND (kcu.TABLE_NAME IN ($placeholders) OR kcu.REFERENCED_TABLE_NAME IN ($placeholders))";
            $params = \array_merge($this->tables, $this->tables);
        }

        $stmt = $this->pdo->prepare("
            SELECT
                kcu.CONSTRAINT_NAME,
                kcu.TABLE_NAME as source_table,
                kcu.COLUMN_NAME as source_column,
                kcu.REFERENCED_TABLE_NAME as target_table,
                kcu.REFERENCED_COLUMN_NAME as target_column,
                rc.UPDATE_RULE,
                rc.DELETE_RULE
            FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
            JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
                ON kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
                AND kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
            $whereClause
            ORDER BY kcu.TABLE_NAME, kcu.ORDINAL_POSITION
        ");

        $stmt->execute($params);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    protected function getIndexes(): array
    {
        $stmt = $this->pdo->prepare("
            SELECT
                TABLE_NAME,
                INDEX_NAME,
                COLUMN_NAME,
                SEQ_IN_INDEX,
                NON_UNIQUE,
                INDEX_TYPE,
                CARDINALITY
            FROM INFORMATION_SCHEMA.STATISTICS
            WHERE TABLE_SCHEMA = DATABASE()
                AND INDEX_NAME != 'PRIMARY'
            ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX
        ");

        $stmt->execute();
        $results = $stmt->fetchAll(PDO::FETCH_ASSOC);

        $indexes = [];
        foreach ($results as $row) {
            $key = $row['TABLE_NAME'] . '.' . $row['INDEX_NAME'];
            if (!isset($indexes[$key])) {
                $indexes[$key] = [
                    'table' => $row['TABLE_NAME'],
                    'name' => $row['INDEX_NAME'],
                    'unique' => $row['NON_UNIQUE'] == 0,
                    'type' => $row['INDEX_TYPE'],
                    'columns' => []
                ];
            }
            $indexes[$key]['columns'][] = $row['COLUMN_NAME'];
        }

        return \array_values($indexes);
    }

    protected function getConstraints(): array
    {
        $whereClause = "WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_TYPE IN ('UNIQUE')";
        $params = [];

        // Add table filtering if specific tables are requested
        if ($this->tables !== null && $this->tables !== []) {
            $placeholders = \str_repeat('?,', \count($this->tables) - 1) . '?';
            $whereClause .= " AND TABLE_NAME IN ($placeholders)";
            $params = $this->tables;
        }

        $stmt = $this->pdo->prepare("
            SELECT
                CONSTRAINT_NAME,
                TABLE_NAME,
                CONSTRAINT_TYPE
            FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
            $whereClause
        ");

        $stmt->execute($params);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    protected function addCommonPatterns(string &$output, array $tables): void
    {
        // Find tables with timestamps for temporal queries
        foreach ($tables as $table) {
            foreach ($table['columns'] as $column) {
                if (\in_array($column['type'], ['timestamp', 'datetime', 'date']) &&
                    (\str_contains(\strtolower((string) $column['name']), 'created') ||
                        \str_contains(\strtolower((string) $column['name']), 'updated'))) {
                    $output .= "- For temporal queries on `{$table['name']}`, use `{$column['name']}` column\n";
                    break;
                }
            }
        }

        // Find potential text search columns
        foreach ($tables as $table) {
            foreach ($table['columns'] as $column) {
                if (\in_array($column['type'], ['varchar', 'text', 'longtext']) &&
                    (\str_contains(\strtolower((string) $column['name']), 'name') ||
                        \str_contains(\strtolower((string) $column['name']), 'title') ||
                        \str_contains(\strtolower((string) $column['name']), 'description'))) {
                    $output .= "- For text searches on `{$table['name']}`, consider using `{$column['name']}` with LIKE or FULLTEXT\n";
                    break;
                }
            }
        }

        $output .= "\n";
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\PostProcessor;

use NeuronAI\Chat\Messages\Message;
use NeuronAI\RAG\Document;

class AdaptiveThresholdPostProcessor implements PostProcessorInterface
{
    /**
     * Creates a post-processor that filters documents using an adaptive threshold
     * based on median and MAD (Median Absolute Deviation).
     *
     * Recommended multiplier values:
     * - 0.2-0.4: High precision mode. For more targeted results with fewer but more relevant documents.
     * - 0.5-0.7: Balanced mode. Recommended setting for general use cases.
     * - 0.8-1.0: High recall mode. For more inclusive results that prioritize coverage.
     * - >1.0: Not recommended as it tends to include almost all documents.
     *
     * @param float $multiplier multiplier for MAD (higher values = more inclusive)
     */
    public function __construct(private readonly float $multiplier = 0.6)
    {
    }

    /**
     * Filters documents using a threshold calculated dynamically with median and MAD
     * for greater robustness against outliers.
     */
    public function process(Message $question, array $documents): array
    {
        if (\count($documents) < 2) {
            return $documents;
        }

        $scores = \array_map(fn (Document $document): float => $document->getScore(), $documents);
        $median = $this->calculateMedian($scores);
        $mad = $this->calculateMAD($scores, $median);

        // If MAD is zero (many equal values), don't filter
        if ($mad <= 0.0001) {
            return $documents;
        }

        // Threshold: median - multiplier * MAD
        $threshold = $median - ($this->multiplier * $mad);

        // Ensure a threshold is not negative
        $threshold = \max(0, $threshold);

        return \array_values(\array_filter($documents, fn (Document $document): bool => $document->getScore() >= $threshold));
    }

    /**
     * Calculates the median of an array of values
     *
     * @param float[] $values
     */
    protected function calculateMedian(array $values): float
    {
        \sort($values);
        $n = \count($values);
        $mid = (int) \floor(($n - 1) / 2);

        if ($n % 2 !== 0) {
            return $values[$mid];
        }

        return ($values[$mid] + $values[$mid + 1]) / 2.0;
    }

    /**
     * Calculates the Median Absolute Deviation (MAD)
     *
     * @param float[] $values
     * @param float $median The median of the values
     */
    protected function calculateMAD(array $values, float $median): float
    {
        $deviations = \array_map(fn (float$v): float => \abs($v - $median), $values);

        // MAD is the median of deviations
        return $this->calculateMedian($deviations);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\Messages;

use NeuronAI\Chat\Attachments\Attachment;
use NeuronAI\Chat\Enums\MessageRole;
use NeuronAI\StaticConstructor;

/**
 * @method static static make(MessageRole $role, array|string|int|float|null $content = null)
 */
class Message implements \JsonSerializable
{
    use StaticConstructor;

    protected ?Usage $usage = null;
    protected array $attachments = [];

    protected array $meta = [];

    public function __construct(
        protected MessageRole $role,
        protected array|string|int|float|null $content = null
    ) {
    }

    public function getRole(): string
    {
        return $this->role->value;
    }

    public function setRole(MessageRole|string $role): Message
    {
        if (!$role instanceof MessageRole) {
            $role = MessageRole::from($role);
        }

        $this->role = $role;
        return $this;
    }

    public function getContent(): mixed
    {
        return $this->content;
    }

    public function setContent(mixed $content): Message
    {
        $this->content = $content;
        return $this;
    }

    /**
     * @return array<Attachment>
     */
    public function getAttachments(): array
    {
        return $this->attachments;
    }

    public function addAttachment(Attachment $attachment): Message
    {
        $this->attachments[] = $attachment;
        return $this;
    }

    public function getUsage(): ?Usage
    {
        return $this->usage;
    }

    public function setUsage(Usage $usage): static
    {
        $this->usage = $usage;
        return $this;
    }

    public function addMetadata(string $key, string|array|null $value): Message
    {
        $this->meta[$key] = $value;
        return $this;
    }

    public function jsonSerialize(): array
    {
        $data = [
            'role' => $this->getRole(),
            'content' => $this->getContent()
        ];

        if ($this->getUsage() instanceof \NeuronAI\Chat\Messages\Usage) {
            $data['usage'] = $this->getUsage()->jsonSerialize();
        }

        if ($this->getAttachments() !== []) {
            $data['attachments'] = \array_map(fn (Attachment $attachment): array => $attachment->jsonSerialize(), $this->getAttachments());
        }

        return \array_merge($this->meta, $data);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\DataLoader;

use NeuronAI\Exceptions\DataReaderException;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;

/**
 * Requires pdftotext php extension.
 *
 * https://en.wikipedia.org/wiki/Pdftotext
 */
class PdfReader implements ReaderInterface
{
    protected string $pdf;

    protected string $binPath;

    protected array $options = [];

    protected int $timeout = 60;

    protected array $env = [];

    protected array $commonPaths = [
        '/usr/bin/pdftotext',          // Common on Linux
        '/usr/local/bin/pdftotext',    // Common on Linux
        '/opt/homebrew/bin/pdftotext', // Homebrew on macOS (Apple Silicon)
        '/opt/local/bin/pdftotext',    // MacPorts on macOS
        '/usr/local/bin/pdftotext',    // Homebrew on macOS (Intel)
    ];

    public function __construct(?string $binPath = null)
    {
        if (!\is_null($binPath)) {
            $this->setBinPath($binPath);
        }
    }

    public function setBinPath(string $binPath): self
    {
        if (!\is_executable($binPath)) {
            throw new DataReaderException("The provided path is not executable.");
        }
        $this->binPath = $binPath;
        return $this;
    }

    protected function findPdfToText(): string
    {
        if (isset($this->binPath)) {
            return $this->binPath;
        }

        foreach ($this->commonPaths as $path) {
            if (\is_executable($path)) {
                return $path;
            }
        }

        throw new DataReaderException("The pdftotext binary was not found or is not executable.");
    }

    public function setPdf(string $pdf): self
    {
        if (!\is_readable($pdf)) {
            throw new DataReaderException("Could not read `{$pdf}`");
        }

        $this->pdf = $pdf;

        return $this;
    }

    public function setOptions(array $options): self
    {
        $this->options = $this->parseOptions($options);

        return $this;
    }

    public function addOptions(array $options): self
    {
        $this->options = \array_merge(
            $this->options,
            $this->parseOptions($options)
        );

        return $this;
    }

    protected function parseOptions(array $options): array
    {
        $mapper = function (string $content): array {
            $content = \trim($content);
            if ('-' !== ($content[0] ?? '')) {
                $content = '-' . $content;
            }

            return \explode(' ', $content, 2);
        };

        $reducer = fn (array $carry, array $option): array => \array_merge($carry, $option);

        return \array_reduce(\array_map($mapper, $options), $reducer, []);
    }

    public function setTimeout(int $timeout): self
    {
        $this->timeout = $timeout;
        return $this;
    }

    public function text(): string
    {
        $process = new Process(\array_merge([$this->findPdfToText()], $this->options, [$this->pdf, '-']));
        $process->setTimeout($this->timeout);
        $process->run();
        if (!$process->isSuccessful()) {
            throw new ProcessFailedException($process);
        }

        return \trim($process->getOutput(), " \t\n\r\0\x0B\x0C");
    }

    /**
     * @throws \Exception
     */
    public static function getText(
        string $filePath,
        array $options = []
    ): string {
        /** @phpstan-ignore new.static */
        $instance = new static();
        $instance->setPdf($filePath);

        if (\array_key_exists('binPath', $options)) {
            $instance->setBinPath($options['binPath']);
        }

        if (\array_key_exists('options', $options)) {
            $instance->setOptions($options['options']);
        }

        if (\array_key_exists('timeout', $options)) {
            $instance->setTimeout($options['timeout']);
        }

        return $instance->text();
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

use NeuronAI\StructuredOutput\StructuredOutputException;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class Length extends AbstractValidationRule
{
    public function __construct(
        protected ?int $exactly = null,
        protected ?int $min = null,
        protected ?int $max = null,
    ) {
    }

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if (null !== $this->exactly && null === $this->min && null === $this->max) {
            $this->min = $this->max = $this->exactly;
        }

        if (null === $this->min && null === $this->max) {
            throw new StructuredOutputException('Either option "min" or "max" must be given for validation rule "Length"');
        }

        if (\is_null($value) && ($this->min > 0 || $this->exactly > 0)) {
            $violations[] = $this->buildMessage($name, '{name} cannot be empty');
            return;
        }

        if (!\is_scalar($value) && !$value instanceof \Stringable) {
            $violations[] = $this->buildMessage($name, '{name} must be a scalar or a stringable object');
            return;
        }

        $stringValue = (string) $value;

        $length = \mb_strlen($stringValue);

        if (null !== $this->max && $length > $this->max) {
            $shouldExact = $this->min == $this->max;

            if ($shouldExact) {
                $violations[] = $this->buildMessage($name, '{name} must be exactly {exact} characters long', ['exact' => $this->min]);
            } else {
                $violations[] = $this->buildMessage($name, '{name} is too long. It must be at most {max} characters', ['max' => $this->max]);
            }
        }

        if (null !== $this->min && $length < $this->min) {
            $shouldExact = $this->min == $this->max;

            if ($shouldExact) {
                $violations[] = $this->buildMessage($name, '{name} must be exactly {exact} characters long', ['exact' => $this->min]);
            } else {
                $violations[] = $this->buildMessage($name, '{name} is too short. It must be at least {min} characters', ['min' => $this->min]);
            }
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\History;

use NeuronAI\Exceptions\ChatHistoryException;

class FileChatHistory extends AbstractChatHistory
{
    public function __construct(
        protected string $directory,
        protected string $key,
        int $contextWindow = 50000,
        protected string $prefix = 'neuron_',
        protected string $ext = '.chat'
    ) {
        parent::__construct($contextWindow);

        if (!\is_dir($this->directory)) {
            throw new ChatHistoryException("Directory '{$this->directory}' does not exist");
        }

        $this->load();
    }

    protected function load(): void
    {
        if (\is_file($this->getFilePath())) {
            $messages = \json_decode(\file_get_contents($this->getFilePath()), true) ?? [];
            $this->history = $this->deserializeMessages($messages);
        }
    }

    protected function getFilePath(): string
    {
        return $this->directory . \DIRECTORY_SEPARATOR . $this->prefix.$this->key.$this->ext;
    }

    public function setMessages(array $messages): ChatHistoryInterface
    {
        $this->updateFile();
        return $this;
    }

    protected function clear(): ChatHistoryInterface
    {
        if (!\unlink($this->getFilePath())) {
            throw new ChatHistoryException("Unable to delete file '{$this->getFilePath()}'");
        }
        return $this;
    }

    protected function updateFile(): void
    {
        \file_put_contents($this->getFilePath(), \json_encode($this->jsonSerialize()), \LOCK_EX);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Calculator;

use NeuronAI\Tools\ArrayProperty;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

class MedianTool extends Tool
{
    public function __construct(protected int $precision = 2)
    {
        parent::__construct(
            'calculate_median',
            <<<DESC
Calculates the median (middle value) of a dataset when sorted in ascending order.
For datasets with an even number of values, returns the average of the two middle values.
The median is less affected by outliers than the mean, making it useful for skewed distributions,
income analysis, or when you need a robust measure of central tendency.
DESC
        );
    }

    protected function properties(): array
    {
        return [
            new ArrayProperty(
                name: 'numbers',
                description: 'Array of numerical values',
                required: true,
                items: new ToolProperty(
                    'number',
                    PropertyType::NUMBER,
                    'A numerical value',
                    true,
                )
            ),
        ];
    }

    public function __invoke(array $numbers): float|array
    {
        // Validate input
        if ($numbers === []) {
            return ['error' => 'Data array cannot be empty'];
        }

        // Filter and validate numeric values
        $numericData = \array_filter($numbers, fn (string|float|int $value): bool => \is_numeric($value));

        if ($numericData === []) {
            return ['error' => 'Data array must contain at least one numeric value'];
        }

        // Convert to float values and sort
        $numericData = \array_map('floatval', $numericData);
        \sort($numericData);

        $count = \count($numericData);
        $middle = (int) \floor($count / 2);

        if ($count % 2 === 0) {
            // Even number of elements - average of two middle values
            $median = ($numericData[$middle - 1] + $numericData[$middle]) / 2;
        } else {
            // Odd number of elements - middle value
            $median = $numericData[$middle];
        }

        return \round($median, $this->precision);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\RequestOptions;
use NeuronAI\RAG\Document;

class QdrantVectorStore implements VectorStoreInterface
{
    protected Client $client;

    public function __construct(
        protected string $collectionUrl, // like http://localhost:6333/collections/neuron-ai/
        protected string $key,
        protected int $topK = 4,
    ) {
        $this->client = new Client([
            'base_uri' => \trim($this->collectionUrl, '/').'/',
            'headers' => [
                'Content-Type' => 'application/json',
                'api-key' => $this->key,
            ]
        ]);
    }

    public function addDocument(Document $document): void
    {
        $this->client->put('points', [
            RequestOptions::JSON => [
                'points' => [
                    [
                        'id' => $document->getId(),
                        'payload' => [
                            'content' => $document->getContent(),
                            'sourceType' => $document->getSourceType(),
                            'sourceName' => $document->getSourceName(),
                            'metadata' => $document->metadata,
                        ],
                        'vector' => $document->getEmbedding(),
                    ]
                ]
            ]
        ]);
    }

    /**
     * Bulk save documents.
     *
     * @param Document[] $documents
     * @throws GuzzleException
     */
    public function addDocuments(array $documents): void
    {
        $points = \array_map(fn (Document $document): array => [
            'id' => $document->getId(),
            'payload' => [
                'content' => $document->getContent(),
                'sourceType' => $document->getSourceType(),
                'sourceName' => $document->getSourceName(),
                ...$document->metadata,
            ],
            'vector' => $document->getEmbedding(),
        ], $documents);

        $this->client->put('points', [
            RequestOptions::JSON => ['points' => $points]
        ]);
    }

    /**
     * @throws GuzzleException
     */
    public function deleteBySource(string $sourceType, string $sourceName): void
    {
        $this->client->post('points/delete', [
            RequestOptions::JSON => [
                'wait' => true,
                'filter' => [
                    'must' => [
                        [
                            'key' => 'sourceType',
                            'match' => [
                                'value' => $sourceType,
                            ]
                        ],
                        [
                            'key' => 'sourceName',
                            'match' => [
                                'value' => $sourceName,
                            ]
                        ]
                    ]
                ]
            ]
        ]);
    }

    public function similaritySearch(array $embedding): iterable
    {
        $response = $this->client->post('points/search', [
            RequestOptions::JSON => [
                'vector' => $embedding,
                'limit' => $this->topK,
                'with_payload' => true,
                'with_vector' => true,
            ]
        ])->getBody()->getContents();

        $response = \json_decode($response, true);

        return \array_map(function (array $item): Document {
            $document = new Document($item['payload']['content']);
            $document->id = $item['id'];
            $document->embedding = $item['vector'];
            $document->sourceType = $item['payload']['sourceType'];
            $document->sourceName = $item['payload']['sourceName'];
            $document->score = $item['score'];

            foreach ($item['payload'] as $name => $value) {
                if (!\in_array($name, ['content', 'sourceType', 'sourceName', 'score', 'embedding', 'id'])) {
                    $document->addMetadata($name, $value);
                }
            }

            return $document;
        }, $response['result']);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability;

use NeuronAI\AgentInterface;
use NeuronAI\Observability\Events\Deserialized;
use NeuronAI\Observability\Events\Deserializing;
use NeuronAI\Observability\Events\Extracted;
use NeuronAI\Observability\Events\Extracting;
use NeuronAI\Observability\Events\SchemaGenerated;
use NeuronAI\Observability\Events\SchemaGeneration;
use NeuronAI\Observability\Events\Validated;
use NeuronAI\Observability\Events\Validating;

trait HandleStructuredEvents
{
    protected function schemaGeneration(AgentInterface $agent, string $event, SchemaGeneration $data): void
    {
        if (!$this->inspector->canAddSegments()) {
            return;
        }

        $this->segments[$data->class.'-schema'] = $this->inspector->startSegment('neuron-schema-generation', "schema_generate( {$data->class} )")
            ->setColor(self::SEGMENT_COLOR);
    }

    protected function schemaGenerated(AgentInterface $agent, string $event, SchemaGenerated $data): void
    {
        if (\array_key_exists($data->class.'-schema', $this->segments)) {
            $segment = $this->segments[$data->class.'-schema']->end();
            $segment->addContext('Schema', $data->schema);
        }
    }

    protected function extracting(AgentInterface $agent, string $event, Extracting $data): void
    {
        if (!$this->inspector->canAddSegments()) {
            return;
        }

        $id = $this->getMessageId($data->message).'-extract';

        $this->segments[$id] = $this->inspector->startSegment('neuron-structured-extract', 'extract_output')
            ->setColor(self::SEGMENT_COLOR);
    }

    protected function extracted(AgentInterface $agent, string $event, Extracted $data): void
    {
        $id = $this->getMessageId($data->message).'-extract';

        if (\array_key_exists($id, $this->segments)) {
            $segment = $this->segments[$id]->end();
            $segment->addContext(
                'Data',
                [
                    'response' => $data->message->jsonSerialize(),
                    'json' => $data->json,
                ]
            )->addContext(
                'Schema',
                $data->schema
            );
            unset($this->segments[$id]);
        }
    }

    protected function deserializing(AgentInterface $agent, string $event, Deserializing $data): void
    {
        if (!$this->inspector->canAddSegments()) {
            return;
        }

        $this->segments[$data->class.'-deserialize'] = $this->inspector->startSegment('neuron-structured-deserialize', "deserialize( {$data->class} )")
            ->setColor(self::SEGMENT_COLOR);
    }

    protected function deserialized(AgentInterface $agent, string $event, Deserialized $data): void
    {
        $id = $data->class.'-deserialize';

        if (\array_key_exists($id, $this->segments)) {
            $this->segments[$id]->end();
        }
    }

    protected function validating(AgentInterface $agent, string $event, Validating $data): void
    {
        if (!$this->inspector->canAddSegments()) {
            return;
        }

        $this->segments[$data->class.'-validate'] = $this->inspector->startSegment('neuron-structured-validate', "validate( {$data->class} )")
            ->setColor(self::SEGMENT_COLOR);
    }

    protected function validated(AgentInterface $agent, string $event, Validated $data): void
    {
        $id = $data->class.'-validate';

        if (\array_key_exists($id, $this->segments)) {
            $segment = $this->segments[$id]->end();
            $segment->addContext('Json', \json_decode($data->json));
            if ($data->violations !== []) {
                $segment->addContext('Violations', $data->violations);
            }
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

use NeuronAI\StructuredOutput\StructuredOutputException;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class Enum extends AbstractValidationRule
{
    protected string $message = '{name} must be one of the following allowed values: {choices}.';

    /**
     * @param array|null $values List of allowed values. Cannot be used with `enum`.
     * @param string|null $class Enum class name to extract choices from. Cannot be used with `choices`.
     *
     * @throws StructuredOutputException if both `choices` and `enum` are provided or if `enum` is not valid.
     */
    public function __construct(
        protected ?array  $values = [],
        protected ?string $class = null,
    ) {
        if ($this->values !== null && $this->values !== [] && $this->class !== null) {
            throw new StructuredOutputException('You cannot provide both "choices" and "enum" options simultaneously. Please use only one.');
        }

        if (($this->values === null || $this->values === []) && $this->class === null) {
            throw new StructuredOutputException('Either option "choices" or "enum" must be given for validation rule "Enum"');
        }

        if ($this->values === null || $this->values === []) {
            $this->handleEnum();
        }
    }

    public function validate(string $name, mixed $value, array &$violations): void
    {
        $value = $value instanceof \BackedEnum ? $value->value : $value;

        if (!\in_array($value, $this->values, true)) {
            $violations[] = $this->buildMessage($name, $this->message, ['choices' => \implode(", ", $this->values)]);
        }
    }

    /**
     * @throws StructuredOutputException
     */
    private function handleEnum(): void
    {
        if (!\enum_exists($this->class)) {
            throw new StructuredOutputException("Enum '{$this->class}' does not exist.");
        }

        if (!\is_subclass_of($this->class, \BackedEnum::class)) {
            throw new StructuredOutputException("Enum '{$this->class}' must implement BackedEnum.");
        }

        $this->values = \array_map(fn (\BackedEnum $case): int|string => $case->value, $this->class::cases());
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\Embeddings;

use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use NeuronAI\RAG\Document;

class VoyageEmbeddingsProvider extends AbstractEmbeddingsProvider
{
    protected Client $client;

    protected string $baseUri = 'https://api.voyageai.com/v1/embeddings';

    public function __construct(
        string $key,
        protected string $model,
        protected ?int $dimensions = null
    ) {
        $this->client = new Client([
            'base_uri' => $this->baseUri,
            'headers' => [
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
                'Authorization' => 'Bearer ' . $key,
            ],
        ]);
    }

    public function embedText(string $text): array
    {
        $response = $this->client->post('', [
            RequestOptions::JSON => [
                'model' => $this->model,
                'input' => $text,
                'output_dimension' => $this->dimensions,
            ]
        ])->getBody()->getContents();

        $response = \json_decode($response, true);

        return $response['data'][0]['embedding'];
    }

    public function embedDocuments(array $documents): array
    {
        $chunks = \array_chunk($documents, 100);

        foreach ($chunks as $chunk) {
            $response = $this->client->post('', [
                RequestOptions::JSON => [
                    'model' => $this->model,
                    'input' => \array_map(fn (Document $document): string => $document->getContent(), $chunk),
                    'output_dimension' => $this->dimensions,
                ]
            ])->getBody()->getContents();

            $response = \json_decode($response, true);

            foreach ($response['data'] as $index => $item) {
                $chunk[$index]->embedding = $item['embedding'];
            }
        }

        return \array_merge(...$chunks);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools;

use NeuronAI\Exceptions\ArrayPropertyException;
use NeuronAI\StaticConstructor;

/**
 * @method static static make(string $name, string $description, bool $required = false, ?ToolPropertyInterface $items = null, ?int $minItems = null, ?int $maxItems = null)
 */
class ArrayProperty implements ToolPropertyInterface
{
    use StaticConstructor;

    protected PropertyType $type = PropertyType::ARRAY;

    /**
     * @throws ArrayPropertyException
     */
    public function __construct(
        protected string $name,
        protected ?string $description = null,
        protected bool $required = false,
        protected ?ToolPropertyInterface $items = null,
        protected ?int $minItems = null,
        protected ?int $maxItems = null,
    ) {
        $this->validateConstraints();
    }

    public function jsonSerialize(): array
    {
        return [
            'name' => $this->name,
            'description' => $this->description,
            'type' => $this->type->value,
            'items' => $this->getJsonSchema(),
            'required' => $this->required,
        ];
    }

    public function getJsonSchema(): array
    {
        $schema = [
            'type' => $this->type->value,
        ];

        if (!\is_null($this->description)) {
            $schema['description'] = $this->description;
        }

        if ($this->items instanceof ToolPropertyInterface) {
            $schema['items'] = $this->items->getJsonSchema();
        }

        if ($this->minItems !== null && $this->minItems !== 0) {
            $schema['minItems'] = $this->minItems;
        }

        if ($this->maxItems !== null && $this->maxItems !== 0) {
            $schema['maxItems'] = $this->maxItems;
        }

        return $schema;
    }

    public function isRequired(): bool
    {
        return $this->required;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getType(): PropertyType
    {
        return $this->type;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function getItems(): ?ToolPropertyInterface
    {
        return $this->items;
    }

    /**
     * @throws ArrayPropertyException
     */
    protected function validateConstraints(): void
    {
        if ($this->minItems !== null && $this->minItems < 0) {
            throw new ArrayPropertyException("minItems must be >= 0, got {$this->minItems}");
        }

        if ($this->maxItems !== null && $this->maxItems < 0) {
            throw new ArrayPropertyException("maxItems must be >= 0, got {$this->maxItems}");
        }

        if ($this->minItems !== null && $this->maxItems !== null && $this->minItems > $this->maxItems) {
            throw new ArrayPropertyException(
                "minItems ({$this->minItems}) cannot be greater than maxItems ({$this->maxItems})"
            );
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\DataLoader;

use NeuronAI\RAG\Document;

class FileDataLoader extends AbstractDataLoader
{
    /**
     * @var array<string, ReaderInterface>
     */
    protected array $readers = [];

    public function __construct(protected string $path, array $readers = [])
    {
        parent::__construct();
        $this->setReaders($readers);
    }

    public function addReader(string $fileExtension, ReaderInterface $reader): self
    {
        $this->readers[$fileExtension] = $reader;
        return $this;
    }

    public function setReaders(array $readers): self
    {
        $this->readers = $readers;
        return $this;
    }

    public function getDocuments(): array
    {
        if (! \file_exists($this->path)) {
            return [];
        }

        // If it's a directory
        if (\is_dir($this->path)) {
            return $this->getDocumentsFromDirectory($this->path);
        }

        // If it's a file
        try {
            return $this->splitter->splitDocument($this->getDocument($this->getContentFromFile($this->path), $this->path));
        } catch (\Throwable) {
            return [];
        }
    }

    protected function getDocumentsFromDirectory(string $directory): array
    {
        $documents = [];
        // Open the directory
        if ($handle = \opendir($directory)) {
            // Read the directory contents
            while (($entry = \readdir($handle)) !== false) {
                $fullPath = $directory.'/'.$entry;
                if ($entry !== '.' && $entry !== '..') {
                    if (\is_dir($fullPath)) {
                        $documents = [...$documents, ...$this->getDocumentsFromDirectory($fullPath)];
                    } else {
                        try {
                            $documents[] = $this->getDocument($this->getContentFromFile($fullPath), $entry);
                        } catch (\Throwable) {
                        }
                    }
                }
            }

            // Close the directory
            \closedir($handle);
        }

        return $this->splitter->splitDocuments($documents);
    }

    /**
     * Transform files to plain text.
     *
     * Supported PDF and plain text files.
     *
     * @throws \Exception
     */
    protected function getContentFromFile(string $path): string|false
    {
        $fileExtension = \strtolower(\pathinfo($path, \PATHINFO_EXTENSION));

        if (\array_key_exists($fileExtension, $this->readers)) {
            $reader = $this->readers[$fileExtension];
            return $reader::getText($path);
        }

        return TextFileReader::getText($path);
    }

    protected function getDocument(string $content, string $entry): Document
    {
        $document = new Document($content);
        $document->sourceType = 'files';
        $document->sourceName = $entry;

        return $document;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Workflow;

use NeuronAI\Exceptions\WorkflowException;
use NeuronAI\Observability\Events\AgentError;
use NeuronAI\Observability\Events\WorkflowEnd;
use NeuronAI\Observability\Events\WorkflowNodeEnd;
use NeuronAI\Observability\Events\WorkflowNodeStart;
use NeuronAI\Observability\Events\WorkflowStart;
use NeuronAI\Observability\Observable;
use NeuronAI\Workflow\Exporter\ExporterInterface;
use NeuronAI\Workflow\Exporter\MermaidExporter;
use NeuronAI\Workflow\Persistence\InMemoryPersistence;
use NeuronAI\Workflow\Persistence\PersistenceInterface;
use SplSubject;

/**
 * @method static static make(?PersistenceInterface $persistence = null, ?string $workflowId = null)
 */
class Workflow implements SplSubject
{
    use Observable;

    /**
     * @var NodeInterface[]
     */
    protected array $nodes = [];

    /**
     * @var Edge[]
     */
    protected array $edges = [];

    protected ?string $startNode = null;

    protected ?array $endNodes = null;

    protected ExporterInterface $exporter;

    protected PersistenceInterface $persistence;

    protected string $workflowId;

    public function __construct(?PersistenceInterface $persistence = null, ?string $workflowId = null)
    {
        $this->exporter = new MermaidExporter();

        if (\is_null($persistence) && !\is_null($workflowId)) {
            throw new WorkflowException('Persistence must be defined when workflowId is defined');
        }
        if (\is_null($workflowId) && !\is_null($persistence)) {
            throw new WorkflowException('WorkflowId must be defined when persistence is defined');
        }

        $this->persistence = $persistence ?? new InMemoryPersistence();
        $this->workflowId = $workflowId ?? \uniqid('neuron_workflow_');
    }

    public function validate(): void
    {
        /*if ($this->getStartNode() === null) {
            throw new WorkflowException('Start node must be defined');
        }

        if ($this->getEndNode() === null) {
            throw new WorkflowException('End node must be defined');
        }*/

        if (!isset($this->getNodes()[$this->getStartNode()])) {
            throw new WorkflowException("Start node {$this->getStartNode()} does not exist");
        }

        foreach ($this->getEndNodes() as $endNode) {
            if (!isset($this->getNodes()[$endNode])) {
                throw new WorkflowException("End node {$endNode} does not exist");
            }
        }

        foreach ($this->getEdges() as $edge) {
            if (!isset($this->getNodes()[$edge->getFrom()])) {
                throw new WorkflowException("Edge from node {$edge->getFrom()} does not exist");
            }

            if (!isset($this->getNodes()[$edge->getTo()])) {
                throw new WorkflowException("Edge to node {$edge->getTo()} does not exist");
            }
        }
    }

    /**
     * @throws WorkflowInterrupt|WorkflowException|\Throwable
     */
    protected function execute(
        string $currentNode,
        WorkflowState $state,
        bool $resuming = false,
        array|string|int $humanFeedback = []
    ): WorkflowState {
        $context = new WorkflowContext(
            $this->workflowId,
            $currentNode,
            $this->persistence,
            $state
        );

        if ($resuming) {
            $context->setResuming(true, [$currentNode => $humanFeedback]);
        }

        try {
            while (!\in_array($currentNode, $this->getEndNodes())) {
                $node = $this->nodes[$currentNode];
                $node->setContext($context);

                $this->notify('workflow-node-start', new WorkflowNodeStart($currentNode, $state));
                try {
                    $state = $node->run($state);
                } catch (WorkflowInterrupt $interrupt) {
                    throw $interrupt;
                } catch (\Throwable $exception) {
                    $this->notify('error', new AgentError($exception));
                    throw $exception;
                }
                $this->notify('workflow-node-end', new WorkflowNodeEnd($currentNode, $state));

                $nextNode = $this->findNextNode($currentNode, $state);

                if ($nextNode === null) {
                    throw new WorkflowException("No valid edge found from node {$currentNode}");
                }

                $currentNode = $nextNode;

                // Update the context before the next iteration or end node
                $context = new WorkflowContext(
                    $this->workflowId,
                    $currentNode,
                    $this->persistence,
                    $state
                );
            }

            $endNode = $this->nodes[$currentNode];
            $endNode->setContext($context);
            $result = $endNode->run($state);
            $this->persistence->delete($this->workflowId);
            return $result;

        } catch (WorkflowInterrupt $interrupt) {
            $this->persistence->save($this->workflowId, $interrupt);
            $this->notify('workflow-interrupt', $interrupt);
            throw $interrupt;
        }
    }

    /**
     * @throws WorkflowInterrupt|WorkflowException|\Throwable
     */
    public function run(?WorkflowState $initialState = null): WorkflowState
    {
        $this->notify('workflow-start', new WorkflowStart($this->getNodes(), $this->getEdges()));
        try {
            $this->validate();
        } catch (WorkflowException $exception) {
            $this->notify('error', new AgentError($exception));
            throw $exception;
        }

        $state = $initialState ?? new WorkflowState();
        $currentNode = $this->getStartNode();

        $result = $this->execute($currentNode, $state);
        $this->notify('workflow-end', new WorkflowEnd($result));

        return $result;
    }

    /**
     * @throws WorkflowInterrupt|WorkflowException|\Throwable
     */
    public function resume(array|string|int $humanFeedback): WorkflowState
    {
        $this->notify('workflow-resume', new WorkflowStart($this->getNodes(), $this->getEdges()));
        $interrupt = $this->persistence->load($this->workflowId);

        $state = $interrupt->getState();
        $currentNode = $interrupt->getCurrentNode();

        $result = $this->execute(
            $currentNode,
            $state,
            true,
            $humanFeedback
        );
        $this->notify('workflow-end', new WorkflowEnd($result));

        return  $result;
    }

    /**
     * @return Node[]
     */
    protected function nodes(): array
    {
        return [];
    }

    /**
     * @return Edge[]
     */
    protected function edges(): array
    {
        return [];
    }

    public function addNode(NodeInterface $node): self
    {
        $this->nodes[$node::class] = $node;
        return $this;
    }

    /**
     * @param NodeInterface[] $nodes
     */
    public function addNodes(array $nodes): Workflow
    {
        foreach ($nodes as $node) {
            $this->addNode($node);
        }
        return $this;
    }

    /**
     * @return array<string, NodeInterface>
     */
    public function getNodes(): array
    {
        if ($this->nodes === []) {
            foreach ($this->nodes() as $node) {
                $this->addNode($node);
            }
        }

        return $this->nodes;
    }

    public function addEdge(Edge $edge): self
    {
        $this->edges[] = $edge;
        return $this;
    }

    /**
     * @param Edge[] $edges
     */
    public function addEdges(array $edges): Workflow
    {
        foreach ($edges as $edge) {
            $this->addEdge($edge);
        }
        return $this;
    }

    /**
     * @return Edge[]
     */
    public function getEdges(): array
    {
        if ($this->edges === []) {
            $this->edges = $this->edges();
        }

        return $this->edges;
    }

    public function setStart(string $nodeClass): Workflow
    {
        $this->startNode = $nodeClass;
        return $this;
    }

    public function setEnd(string $nodeClass): Workflow
    {
        $this->endNodes[] = $nodeClass;
        return $this;
    }

    protected function getStartNode(): string
    {
        return $this->startNode ?? $this->start();
    }

    protected function getEndNodes(): array
    {
        return $this->endNodes ?? $this->end();
    }

    protected function start(): ?string
    {
        throw new WorkflowException('Start node must be defined');
    }

    protected function end(): array
    {
        throw new WorkflowException('End node must be defined');
    }

    private function findNextNode(string $currentNode, WorkflowState $state): ?string
    {
        foreach ($this->getEdges() as $edge) {
            if ($edge->getFrom() === $currentNode && $edge->shouldExecute($state)) {
                return $edge->getTo();
            }
        }

        return null;
    }

    public function getWorkflowId(): string
    {
        return $this->workflowId;
    }

    public function export(): string
    {
        return $this->exporter->export($this);
    }

    public function setExporter(ExporterInterface $exporter): Workflow
    {
        $this->exporter = $exporter;
        return $this;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Calculator;

use NeuronAI\Tools\ArrayProperty;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

class MeanTool extends Tool
{
    public function __construct(protected int $precision = 2)
    {
        parent::__construct(
            'calculate_mean',
            <<<DESC
Calculates the arithmetic mean (average) of a dataset. The mean is the sum of all values divided
by the number of values. Use this tool when you need the central tendency of numerical data,
analyzing performance metrics, calculating average scores, or determining typical values in a
dataset. Input should be an array of numbers.
DESC
        );
    }

    protected function properties(): array
    {
        return [
            new ArrayProperty(
                name: 'numbers',
                description: 'Array of numerical values',
                required: true,
                items: new ToolProperty(
                    'number',
                    PropertyType::NUMBER,
                    'A numerical value',
                    true,
                )
            ),
        ];
    }

    public function __invoke(array $numbers): float|array
    {
        // Validate input
        if ($numbers === []) {
            return ['error' => 'Data array cannot be empty'];
        }

        // Filter and validate numeric values
        $numericData = \array_filter($numbers, fn (string|float|int $value): bool => \is_numeric($value));

        if ($numericData === []) {
            return ['error' => 'Data array must contain at least one numeric value'];
        }

        // Convert to float values
        $numericData = \array_map('floatval', $numericData);
        $mean = \array_sum($numericData) / \count($numericData);

        return \round($mean, $this->precision);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

use NeuronAI\StructuredOutput\StructuredOutputException;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class Json extends AbstractValidationRule
{
    protected string $message = '{name} must be a valid JSON string';

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if (null === $value || '' === $value) {
            return;
        }

        if (!\is_scalar($value) && !$value instanceof \Stringable) {
            throw new StructuredOutputException('Cannot validate a non-scalar value.');
        }

        $value = (string) $value;

        try {
            \json_decode($value, true, 512, \JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            $violations[] = $this->buildMessage($name, $this->message);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class NotBlank extends AbstractValidationRule
{
    protected string $message = '{name} cannot be blank';

    public function __construct(
        protected bool $allowNull = false
    ) {
    }

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if ($this->allowNull && $value === null) {
            return;
        }

        if (false === $value || (empty($value) && '0' != $value)) {
            $violations[] = $this->buildMessage($name, $this->message);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools;

use NeuronAI\Exceptions\ArrayPropertyException;
use NeuronAI\Exceptions\ToolException;
use NeuronAI\StaticConstructor;
use NeuronAI\StructuredOutput\JsonSchema;

/**
 * @method static static make(string $name, string $description, bool $required = false, ?string $class = null, array $properties = [])
 */
class ObjectProperty implements ToolPropertyInterface
{
    use StaticConstructor;

    protected PropertyType $type = PropertyType::OBJECT;

    /**
     * @param string|null $class The associated class name, or null if not applicable.
     * @param ToolPropertyInterface[] $properties An array of additional properties.
     * @throws \ReflectionException
     * @throws ToolException
     * @throws ArrayPropertyException
     */
    public function __construct(
        protected string $name,
        protected ?string $description = null,
        protected bool $required = false,
        protected ?string $class = null,
        protected array $properties = [],
    ) {
        if ($this->properties === [] && \class_exists($this->class)) {
            $schema = (new JsonSchema())->generate($this->class);
            $this->properties = $this->buildPropertiesFromClass($schema);
        }
    }

    /**
     * Recursively build properties from a class schema
     *
     * @return ToolPropertyInterface[]
     * @throws \ReflectionException
     * @throws ToolException
     * @throws ArrayPropertyException
     */
    protected function buildPropertiesFromClass(array $schema): array
    {
        $required = $schema['required'] ?? [];
        $properties = [];

        foreach ($schema['properties'] as $propertyName => $propertyData) {
            $isRequired = \in_array($propertyName, $required);
            $property = $this->createPropertyFromSchema($propertyName, $propertyData, $isRequired);

            if ($property instanceof ToolPropertyInterface) {
                $properties[] = $property;
            }
        }

        return $properties;
    }

    /**
     * Create a property from schema data recursively
     *
     * @throws \ReflectionException
     * @throws ToolException
     * @throws ArrayPropertyException
     */
    protected function createPropertyFromSchema(string $propertyName, array $propertyData, bool $isRequired): ?ToolPropertyInterface
    {
        $type = $propertyData['type'] ?? 'string';
        $description = $propertyData['description'] ?? null;

        return match ($type) {
            'object' => $this->createObjectProperty($propertyName, $propertyData, $isRequired, $description),
            'array' => $this->createArrayProperty($propertyName, $propertyData, $isRequired, $description),
            'string', 'integer', 'number', 'boolean' => $this->createScalarProperty($propertyName, $propertyData, $isRequired, $description),
            default => new ToolProperty(
                $propertyName,
                PropertyType::STRING,
                $description,
                $isRequired,
                $propertyData['enum'] ?? []
            ),
        };
    }

    /**
     * Create an object property recursively
     *
     * @throws \ReflectionException
     * @throws ToolException
     * @throws ArrayPropertyException
     */
    protected function createObjectProperty(string $name, array $propertyData, bool $required, ?string $description): ObjectProperty
    {
        $nestedProperties = [];
        $nestedRequired = $propertyData['required'] ?? [];

        // If there's a class reference in the schema, use it
        $className = $propertyData['class'] ?? null;

        // If no class is specified, but we have nested properties, build them recursively
        if (!$className && isset($propertyData['properties'])) {
            foreach ($propertyData['properties'] as $nestedPropertyName => $nestedPropertyData) {
                $nestedIsRequired = \in_array($nestedPropertyName, $nestedRequired);
                $nestedProperty = $this->createPropertyFromSchema($nestedPropertyName, $nestedPropertyData, $nestedIsRequired);

                if ($nestedProperty instanceof ToolPropertyInterface) {
                    $nestedProperties[] = $nestedProperty;
                }
            }
        }

        return new ObjectProperty(
            $name,
            $description,
            $required,
            $className,
            $nestedProperties
        );
    }

    /**
     * Create an array property with recursive item handling
     *
     * @throws \ReflectionException
     * @throws ToolException
     * @throws ArrayPropertyException
     */
    protected function createArrayProperty(string $name, array $propertyData, bool $required, ?string $description): ArrayProperty
    {
        $items = null;
        $minItems = $propertyData['minItems'] ?? null;
        $maxItems = $propertyData['maxItems'] ?? null;

        // Handle array items recursively
        if (isset($propertyData['items'])) {
            $itemsData = $propertyData['items'];
            $items = $this->createPropertyFromSchema($name . '_item', $itemsData, false);
        }

        return new ArrayProperty(
            $name,
            $description,
            $required,
            $items,
            $minItems,
            $maxItems
        );
    }

    /**
     * Create a scalar property (string, integer, number, boolean)
     *
     * @throws ToolException
     */
    protected function createScalarProperty(string $name, array $propertyData, bool $required, ?string $description): ToolProperty
    {
        return new ToolProperty(
            $name,
            PropertyType::fromSchema($propertyData['type']),
            $description,
            $required,
            $propertyData['enum'] ?? []
        );
    }

    public function jsonSerialize(): array
    {
        return [
            'name' => $this->name,
            ...(\is_null($this->description) ? [] : ['description' => $this->description]),
            'type' => $this->type,
            'properties' => $this->getJsonSchema(),
            'required' => $this->required,
        ];
    }

    // The mapped class required properties and required properties are merged
    public function getRequiredProperties(): array
    {
        return \array_values(\array_filter(\array_map(fn (
            ToolPropertyInterface $property
        ): ?string => $property->isRequired() ? $property->getName() : null, $this->properties)));
    }

    public function getJsonSchema(): array
    {
        $schema = [
            'type' => $this->type->value,
        ];

        if (!\is_null($this->description)) {
            $schema['description'] = $this->description;
        }

        $properties = \array_reduce($this->properties, function (array $carry, ToolPropertyInterface $property) {
            $carry[$property->getName()] = $property->getJsonSchema();
            return $carry;
        }, []);

        if (!empty($properties)) {
            $schema['properties'] = $properties;
            $schema['required'] = $this->getRequiredProperties();
        }

        return $schema;
    }

    public function isRequired(): bool
    {
        return $this->required;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getType(): PropertyType
    {
        return $this->type;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function getProperties(): array
    {
        return $this->properties;
    }

    public function getClass(): ?string
    {
        return $this->class;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\History;

use NeuronAI\Exceptions\ChatHistoryException;
use PDO;

/**
 *
 * CREATE TABLE chat_history (
 * id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
 * thread_id VARCHAR(255) NOT NULL,
 * messages LONGTEXT NOT NULL,
 * created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 * updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
 *
 * UNIQUE KEY uk_thread_id (thread_id),
 * INDEX idx_thread_id (thread_id)
 * );
 *
 */
class SQLChatHistory extends AbstractChatHistory
{
    protected string $table;

    public function __construct(
        protected string $thread_id,
        protected PDO $pdo,
        string $table = 'chat_history',
        int $contextWindow = 50000
    ) {
        parent::__construct($contextWindow);
        $this->table = $this->sanitizeTableName($table);
        $this->load();
    }

    protected function load(): void
    {
        $stmt = $this->pdo->prepare("SELECT * FROM {$this->table} WHERE thread_id = :thread_id");
        $stmt->execute(['thread_id' => $this->thread_id]);
        $history = $stmt->fetchAll(PDO::FETCH_ASSOC);

        if (empty($history)) {
            $stmt = $this->pdo->prepare("INSERT INTO {$this->table} (thread_id, messages) VALUES (:thread_id, :messages)");
            $stmt->execute(['thread_id' => $this->thread_id, 'messages' => '[]']);
        } else {
            $this->history = $this->deserializeMessages(\json_decode((string) $history[0]['messages'], true));
        }
    }

    public function setMessages(array $messages): ChatHistoryInterface
    {
        $stmt = $this->pdo->prepare("UPDATE {$this->table} SET messages = :messages WHERE thread_id = :thread_id");
        $stmt->execute(['thread_id' => $this->thread_id, 'messages' => \json_encode($this->jsonSerialize())]);
        return $this;
    }

    protected function clear(): ChatHistoryInterface
    {
        $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE thread_id = :thread_id");
        $stmt->execute(['thread_id' => $this->thread_id]);
        return $this;
    }

    protected function sanitizeTableName(string $tableName): string
    {
        $tableName = \trim($tableName);

        // Whitelist validation
        if (!$this->tableExists($tableName)) {
            throw new ChatHistoryException('Table not allowed');
        }

        // Format validation as backup
        if (\in_array(\preg_match('/^[a-zA-Z_]\w*$/', $tableName), [0, false], true)) {
            throw new ChatHistoryException('Invalid table name format');
        }

        return $tableName;
    }

    protected function tableExists(string $tableName): bool
    {
        $stmt = $this->pdo->prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = :table_name");
        $stmt->execute(['table_name' => $tableName]);
        return $stmt->fetch() !== false;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Calculator;

use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

class FactorialTool extends Tool
{
    public function __construct()
    {
        parent::__construct(
            'calculate_factorial',
            <<<DESC
Calculates the factorial of a non-negative integer. Factorial (n!) is the product of all positive
integers from 1 to n. For example, 5! = 5 × 4 × 3 × 2 × 1 = 120. Use this tool for combinatorics problems,
probability calculations, permutations and combinations, mathematical series, and statistical computations.
The input must be a non-negative integer (0, 1, 2, 3, etc.). Note that 0! = 1 by mathematical convention.
DESC
        );
    }

    protected function properties(): array
    {
        return [
            new ToolProperty(
                name: 'number',
                type: PropertyType::INTEGER,
                description: 'The non-negative integer to calculate the factorial of (must be ≥ 0)',
                required: true
            ),
        ];
    }

    public function __invoke(int $number): int|float|array
    {
        // Validate input
        if ($number < 0) {
            return ['error' => 'Factorial is not defined for negative numbers.'];
        }

        // Handle edge cases
        if ($number === 0 || $number === 1) {
            return 1;
        }

        // For larger numbers, use BCMath to handle arbitrary precision
        if ($number > 20) {
            return self::calculateWithBCMath($number);
        }

        // For smaller numbers, use regular integer calculation
        $result = 1;
        for ($i = 2; $i <= $number; $i++) {
            $result *= $i;
        }

        return $result;
    }

    /**
     * Calculate factorial using BCMath for large numbers
     */
    private function calculateWithBCMath(int $number): float
    {
        $result = '1';

        for ($i = 2; $i <= $number; $i++) {
            $result = \bcmul($result, (string)$i);
        }

        return (float)$result;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\PostProcessor;

use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\RAG\Document;

class CohereRerankerPostProcessor implements PostProcessorInterface
{
    protected Client $client;

    public function __construct(
        protected string $key,
        protected string $model = 'rerank-v3.5',
        protected int $topN = 3
    ) {
    }

    protected function getClient(): Client
    {
        return $this->client ?? $this->client = new Client([
            'base_uri' => 'https://api.cohere.com/v2/',
            'headers' => [
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
                'Authorization' => 'Bearer '.$this->key,
            ],
        ]);
    }

    public function process(Message $question, array $documents): array
    {
        $response = $this->getClient()->post('rerank', [
            RequestOptions::JSON => [
                'model' => $this->model,
                'query' => $question->getContent(),
                'top_n' => $this->topN,
                'documents' => \array_map(fn (Document $document): string => $document->getContent(), $documents),
            ],
        ])->getBody()->getContents();

        $result = \json_decode($response, true);

        return \array_map(function (array $item) use ($documents): Document {
            $document = $documents[$item['index']];
            $document->setScore($item['relevance_score']);
            return $document;
        }, $result['results']);
    }

    public function setClient(Client $client): PostProcessorInterface
    {
        $this->client = $client;
        return $this;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG;

use NeuronAI\Agent;
use NeuronAI\AgentInterface;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Exceptions\AgentException;
use NeuronAI\Observability\Events\PostProcessed;
use NeuronAI\Observability\Events\PostProcessing;
use NeuronAI\Observability\Events\PreProcessed;
use NeuronAI\Observability\Events\PreProcessing;
use NeuronAI\Observability\Events\Retrieved;
use NeuronAI\Observability\Events\Retrieving;
use NeuronAI\Exceptions\MissingCallbackParameter;
use NeuronAI\Exceptions\ToolCallableNotSet;
use NeuronAI\Providers\AIProviderInterface;
use NeuronAI\RAG\PostProcessor\PostProcessorInterface;
use NeuronAI\RAG\PreProcessor\PreProcessorInterface;

/**
 * @method RAG withProvider(AIProviderInterface $provider)
 */
class RAG extends Agent
{
    use ResolveVectorStore;
    use ResolveEmbeddingProvider;

    /**
     * @var PreProcessorInterface[]
     */
    protected array $preProcessors = [];

    /**
     * @var PostProcessorInterface[]
     */
    protected array $postProcessors = [];

    /**
     * @deprecated TUse "chat" instead
     */
    public function answer(Message $question): Message
    {
        return $this->chat($question);
    }

    /**
     * @deprecated Use "stream" instead
     */
    public function answerStream(Message $question): \Generator
    {
        return $this->stream($question);
    }

    /**
     * @throws MissingCallbackParameter
     * @throws ToolCallableNotSet
     * @throws \Throwable
     */
    public function chat(Message|array $messages): Message
    {
        $question = \is_array($messages) ? \end($messages) : $messages;

        $this->notify('chat-rag-start');

        $this->retrieval($question);

        $response = parent::chat($messages);

        $this->notify('chat-rag-stop');
        return $response;
    }

    public function stream(Message|array $messages): \Generator
    {
        $question = \is_array($messages) ? \end($messages) : $messages;

        $this->notify('stream-rag-start');

        $this->retrieval($question);

        yield from parent::stream($messages);

        $this->notify('stream-rag-stop');
    }

    public function structured(Message|array $messages, ?string $class = null, int $maxRetries = 1): mixed
    {
        $question = \is_array($messages) ? \end($messages) : $messages;

        $this->notify('structured-rag-start');

        $this->retrieval($question);

        $structured = parent::structured($messages, $class, $maxRetries);

        $this->notify('structured-rag-stop');

        return $structured;
    }

    protected function retrieval(Message $question): void
    {
        $this->withDocumentsContext(
            $this->retrieveDocuments($question)
        );
    }

    /**
     * Set the system message based on the context.
     *
     * @param Document[] $documents
     */
    public function withDocumentsContext(array $documents): AgentInterface
    {
        $originalInstructions = $this->resolveInstructions();

        // Remove the old context to avoid infinite grow
        $newInstructions = $this->removeDelimitedContent($originalInstructions, '<EXTRA-CONTEXT>', '</EXTRA-CONTEXT>');

        $newInstructions .= '<EXTRA-CONTEXT>';
        foreach ($documents as $document) {
            $newInstructions .= "Source Type: ".$document->getSourceType().\PHP_EOL.
                "Source Name: ".$document->getSourceName().\PHP_EOL.
                "Content: ".$document->getContent().\PHP_EOL.\PHP_EOL;
        }
        $newInstructions .= '</EXTRA-CONTEXT>';

        $this->withInstructions(\trim($newInstructions));

        return $this;
    }

    /**
     * Retrieve relevant documents from the vector store.
     *
     * @return Document[]
     */
    public function retrieveDocuments(Message $question): array
    {
        $question = $this->applyPreProcessors($question);

        $this->notify('rag-retrieving', new Retrieving($question));

        $documents = $this->resolveVectorStore()->similaritySearch(
            $this->resolveEmbeddingsProvider()->embedText($question->getContent()),
        );

        $retrievedDocs = [];

        foreach ($documents as $document) {
            //md5 for removing duplicates
            $retrievedDocs[\md5($document->getContent())] = $document;
        }

        $retrievedDocs = \array_values($retrievedDocs);

        $this->notify('rag-retrieved', new Retrieved($question, $retrievedDocs));

        return $this->applyPostProcessors($question, $retrievedDocs);
    }

    /**
     * Apply a series of preprocessors to the asked question.
     *
     * @return Message The processed question.
     */
    protected function applyPreProcessors(Message $question): Message
    {
        foreach ($this->preProcessors() as $processor) {
            $this->notify('rag-preprocessing', new PreProcessing($processor::class, $question));
            $question = $processor->process($question);
            $this->notify('rag-preprocessed', new PreProcessed($processor::class, $question));
        }

        return $question;
    }

    /**
     * Apply a series of postprocessors to the retrieved documents.
     *
     * @param Document[] $documents The documents to process.
     * @return Document[] The processed documents.
     */
    protected function applyPostProcessors(Message $question, array $documents): array
    {
        foreach ($this->postProcessors() as $processor) {
            $this->notify('rag-postprocessing', new PostProcessing($processor::class, $question, $documents));
            $documents = $processor->process($question, $documents);
            $this->notify('rag-postprocessed', new PostProcessed($processor::class, $question, $documents));
        }

        return $documents;
    }

    /**
     * Feed the vector store with documents.
     *
     * @param Document[] $documents
     */
    public function addDocuments(array $documents, int $chunkSize = 50): void
    {
        foreach (\array_chunk($documents, $chunkSize) as $chunk) {
            $this->resolveVectorStore()->addDocuments(
                $this->resolveEmbeddingsProvider()->embedDocuments($chunk)
            );
        }
    }

    /**
     * @param Document[] $documents
     */
    public function reindexBySource(array $documents, int $chunkSize = 50): void
    {
        $grouped = [];

        foreach ($documents as $document) {
            $key = $document->sourceType . ':' . $document->sourceName;

            if (!isset($grouped[$key])) {
                $grouped[$key] = [];
            }

            $grouped[$key][] = $document;
        }

        foreach (\array_keys($grouped) as $key) {
            [$sourceType, $sourceName] = \explode(':', $key);
            $this->resolveVectorStore()->deleteBySource($sourceType, $sourceName);
            $this->addDocuments($grouped[$key], $chunkSize);
        }
    }

    /**
     * @throws AgentException
     */
    public function setPreProcessors(array $preProcessors): RAG
    {
        foreach ($preProcessors as $processor) {
            if (! $processor instanceof PreProcessorInterface) {
                throw new AgentException($processor::class." must implement PreProcessorInterface");
            }

            $this->preProcessors[] = $processor;
        }

        return $this;
    }

    /**
     * @throws AgentException
     */
    public function setPostProcessors(array $postProcessors): RAG
    {
        foreach ($postProcessors as $processor) {
            if (! $processor instanceof PostProcessorInterface) {
                throw new AgentException($processor::class." must implement PostProcessorInterface");
            }

            $this->postProcessors[] = $processor;
        }

        return $this;
    }

    /**
     * @return PreProcessorInterface[]
     */
    protected function preProcessors(): array
    {
        return $this->preProcessors;
    }

    /**
     * @return PostProcessorInterface[]
     */
    protected function postProcessors(): array
    {
        return $this->postProcessors;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation;

class Validator
{
    /**
     * Validate an object
     *
     * @throws \ReflectionException
     */
    public static function validate(mixed $obj): array
    {
        $reflection = new \ReflectionClass($obj);
        $violations = [];

        foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
            // Get all attributes for this property
            $attributes = $property->getAttributes();

            if (empty($attributes)) {
                continue;
            }

            // Get the value of the property
            $name = $property->getName();
            $value = $property->isInitialized($obj) ? $property->getValue($obj) : null;

            // Apply all the validation rules to the value
            foreach ($attributes as $attribute) {
                $instance = $attribute->newInstance();

                // Perform validation
                if ($instance instanceof ValidationRuleInterface) {
                    $instance->validate($name, $value, $violations);
                }
            }
        }

        return $violations;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits;

use NeuronAI\StaticConstructor;
use NeuronAI\Tools\ToolInterface;

abstract class AbstractToolkit implements ToolkitInterface
{
    use StaticConstructor;

    protected array $exclude = [];
    protected array $only = [];

    public function guidelines(): ?string
    {
        return null;
    }

    /**
     * @param  class-string[]  $classes
     */
    public function exclude(array $classes): ToolkitInterface
    {
        $this->exclude = $classes;
        return $this;
    }

    /**
     * @param  class-string[]  $classes
     */
    public function only(array $classes): ToolkitInterface
    {
        $this->only = $classes;
        return $this;
    }

    /**
     * @return ToolInterface[]
     */
    abstract public function provide(): array;

    public function tools(): array
    {
        if ($this->exclude === [] && $this->only === []) {
            return $this->provide();
        }

        return \array_filter(
            $this->provide(),
            fn (ToolInterface $tool): bool => !\in_array($tool::class, $this->exclude)
                && ($this->only === [] || \in_array($tool::class, $this->only))
        );
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Zep;

use GuzzleHttp\RequestOptions;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

/**
 * https://help.getzep.com/sdk-reference/graph/search
 *
 * @method static static make(string $key, string $user_id)
 */
class ZepSearchGraphTool extends Tool
{
    use HandleZepClient;

    public function __construct(
        protected string $key,
        protected string $user_id
    ) {
        parent::__construct(
            'search_knowledge_graph',
            'Searches the knowledge graph for relevant facts or nodes.
Use this tool if you need to retrieve user information that can help you provide more accurate answers.'
        );

        $this->createUser();
    }

    protected function properties(): array
    {
        return [
            new ToolProperty(
                'query',
                PropertyType::STRING,
                'The search term to find relevant facts or nodes',
                true
            ),
            new ToolProperty(
                'search_scope',
                PropertyType::STRING,
                'The scope of the search to perform. Can be "facts" or "nodes"',
                false,
                ['facts', 'nodes']
            )
        ];
    }

    public function __invoke(string $query, string $search_scope = 'facts', int $limit = 5): array
    {
        $response = $this->getClient()->post('graph/search', [
            RequestOptions::JSON => [
                'user_id' => $this->user_id,
                'query' => $query,
                'scope' => $search_scope === 'facts' ? 'edges' : 'nodes',
                'limit' => $limit,
            ]
        ])->getBody()->getContents();

        $response = \json_decode($response, true);

        return match ($search_scope) {
            'nodes' => $this->mapNodes($response['nodes']),
            default => $this->mapEdges($response['edges']),
        };
    }

    protected function mapEdges(array $edges): array
    {
        return \array_map(fn (array $edge): array => [
            'fact' => $edge['fact'],
            'created_at' => $edge['created_at'],
        ], $edges);
    }

    protected function mapNodes(array $nodes): array
    {
        return \array_map(fn (array $node): array => [
            'name' => $node['name'],
            'summary' => $node['summary'],
        ], $nodes);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Zep;

use GuzzleHttp\RequestOptions;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

/**
 * @method static static make(string $key, string $user_id)
 */
class ZepAddToGraphTool extends Tool
{
    use HandleZepClient;

    public function __construct(
        protected string $key,
        protected string $user_id
    ) {
        parent::__construct(
            'add_knowledge_graph_data',
            'Add relevant information to the knowledge graph for long term memory.
Look for facts, news or any relevant information in the conversation that you think is important to store for future use.'
        );

        $this->createUser();
    }

    protected function properties(): array
    {
        return [
            new ToolProperty(
                'data',
                PropertyType::STRING,
                'The search term to find relevant facts or nodes',
                true
            ),
            new ToolProperty(
                'type',
                PropertyType::STRING,
                'The scope of the search to perform. Can be "facts" or "nodes"',
                true,
                ['text', 'json', 'message']
            )
        ];
    }

    public function __invoke(string $data, string $type): string
    {
        $response = $this->getClient()->post('graph', [
            RequestOptions::JSON => [
                'user_id' => $this->user_id,
                'data' => $data,
                'type' => $type,
            ]
        ])->getBody()->getContents();

        $response = \json_decode($response, true);

        return $response['content'];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\PGSQL;

use InvalidArgumentException;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;
use PDO;

/**
 * @method static static make(PDO $pdo)
 */
class PGSQLSelectTool extends Tool
{
    /**
     * Patterns for write operations that should be blocked
     */
    protected array $forbiddenPatterns = [
        '/^\s*(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|REPLACE)\s+/i',
        '/\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|REPLACE)\s+/i',
        '/;\s*(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|REPLACE)\s+/i',
        '/\bINTO\s+OUTFILE\s+/i',
        '/\bLOAD\s+DATA\s+/i',
        '/\bSET\s+/i',
        '/\bCALL\s+/i',
        '/\bEXEC(UTE)?\s+/i',
    ];

    // Allowed read-only statements
    protected array $allowedPatterns = [
        '/^\s*SELECT\s+/i',
        '/^\s*WITH\s+/i', // Common Table Expressions
        '/^\s*EXPLAIN\s+/i',
        '/^\s*SHOW\s+/i',
        '/^\s*DESCRIBE\s+/i',
        '/^\s*DESC\s+/i',
    ];

    public function __construct(protected PDO $pdo)
    {
        parent::__construct(
            'execute_select_query',
            'Use this tool only to run SELECT query against the PostgreSQL database.
This the tool to use only to gather information from the MySQL database.'
        );
    }

    protected function properties(): array
    {
        return [
            new ToolProperty(
                'query',
                PropertyType::STRING,
                'The SELECT query you want to run against the database.',
                true
            )
        ];
    }

    public function __invoke(string $query): array
    {
        if (!$this->validateReadOnlyQuery($query)) {
            return [
                "error" => "The query was rejected for security reasons.
It looks like you are trying to run a write query using the read-only query tool."
            ];
        }

        $statement = $this->pdo->prepare($query);
        $statement->execute();

        return $statement->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Validates that the query is read-only
     *
     * @throws InvalidArgumentException if query contains write operations
     */
    private function validateReadOnlyQuery(string $query): bool
    {
        if ($query === '') {
            return false;
        }

        // Remove comments to avoid false positives
        $cleanQuery = $this->removeComments($query);

        // Check if query starts with an allowed read operation
        $isAllowed = false;
        foreach ($this->allowedPatterns as $pattern) {
            if (\preg_match($pattern, $cleanQuery)) {
                $isAllowed = true;
                break;
            }
        }

        if (!$isAllowed) {
            return false;
        }

        // Check for forbidden write operations
        foreach ($this->forbiddenPatterns as $pattern) {
            if (\preg_match($pattern, $cleanQuery)) {
                return false;
            }
        }

        // Additional security checks
        return $this->performAdditionalSecurityChecks($cleanQuery);
    }

    private function removeComments(string $query): string
    {
        // Remove single-line comments (-- style)
        $query = \preg_replace('/--.*$/m', '', $query);

        // Remove multi-line comments (/* */ style)
        $query = \preg_replace('/\/\*.*?\*\//s', '', (string) $query);

        return $query;
    }

    private function performAdditionalSecurityChecks(string $query): bool
    {
        // Check for semicolon followed by potential write operations
        if (\preg_match('/;\s*(?!$)/i', $query)) {
            // Multiple statements detected - need to validate each one
            $statements = $this->splitStatements($query);
            foreach ($statements as $statement) {
                if (\trim((string) $statement) !== '' && !$this->validateSingleStatement(\trim((string) $statement))) {
                    return false;
                }
            }
        }

        // Check for function calls that might modify data
        $dangerousFunctions = [
            'pg_exec',
            'pg_query',
            'system',
            'exec',
            'shell_exec',
            'passthru',
            'eval',
        ];

        foreach ($dangerousFunctions as $func) {
            if (\stripos($query, $func) !== false) {
                return false;
            }
        }

        return true;
    }

    /**
     * Split query into individual statements
     */
    private function splitStatements(string $query): array
    {
        // Simple split on semicolons (this could be enhanced for more complex cases)
        return \array_filter(
            \array_map('trim', \explode(';', $query)),
            fn (string $stmt): bool => $stmt !== ''
        );
    }

    /**
     * Validate a single statement
     *
     * @return bool True if statement is valid read-only operation, false otherwise
     */
    private function validateSingleStatement(string $statement): bool
    {
        $isAllowed = false;
        foreach ($this->allowedPatterns as $pattern) {
            if (\preg_match($pattern, $statement)) {
                $isAllowed = true;
                break;
            }
        }

        return $isAllowed;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers;

use NeuronAI\Exceptions\ProviderException;
use NeuronAI\Tools\ToolInterface;

trait HandleWithTools
{
    /**
     * https://docs.anthropic.com/en/docs/build-with-claude/tool-use/overview
     *
     * @var array<ToolInterface>
     */
    protected array $tools = [];

    public function setTools(array $tools): AIProviderInterface
    {
        $this->tools = $tools;
        return $this;
    }

    public function findTool(string $name): ToolInterface
    {
        foreach ($this->tools as $tool) {
            if ($tool->getName() === $name) {
                // We return a copy to allow multiple call to the same tool.
                return clone $tool;
            }
        }

        throw new ProviderException(
            "It seems the model is asking for a non-existing tool: {$name}. You could try writing more verbose tool descriptions and prompts to help the model in the task."
        );
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore;

use NeuronAI\Exceptions\VectorStoreException;
use NeuronAI\RAG\Document;

class MemoryVectorStore implements VectorStoreInterface
{
    /**
     * @var Document[]
     */
    private array $documents = [];

    public function __construct(protected int $topK = 4)
    {
    }

    public function addDocument(Document $document): void
    {
        $this->documents[] = $document;
    }

    public function addDocuments(array $documents): void
    {
        $this->documents = \array_merge($this->documents, $documents);
    }

    public function deleteBySource(string $sourceType, string $sourceName): void
    {
        $this->documents = \array_filter($this->documents, fn (Document $document): bool => $document->getSourceType() !== $sourceType || $document->getSourceName() !== $sourceName);
    }

    public function similaritySearch(array $embedding): array
    {
        $distances = [];

        foreach ($this->documents as $index => $document) {
            if ($document->embedding === []) {
                throw new VectorStoreException("Document with the following content has no embedding: {$document->getContent()}");
            }
            $dist = VectorSimilarity::cosineDistance($embedding, $document->getEmbedding());
            $distances[$index] = $dist;
        }

        \asort($distances); // Sort by distance (ascending).

        $topKIndices = \array_slice(\array_keys($distances), 0, $this->topK, true);

        return \array_reduce($topKIndices, function (array $carry, int $index) use ($distances): array {
            $document = $this->documents[$index];
            $document->setScore(VectorSimilarity::similarityFromDistance($distances[$index]));
            $carry[] = $document;
            return $carry;
        }, []);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability;

use NeuronAI\Observability\Events\WorkflowEnd;
use NeuronAI\Observability\Events\WorkflowNodeEnd;
use NeuronAI\Observability\Events\WorkflowNodeStart;
use NeuronAI\Observability\Events\WorkflowStart;
use NeuronAI\Workflow\Edge;

trait HandleWorkflowEvents
{
    public function workflowStart(\SplSubject $workflow, string $event, WorkflowStart $data): void
    {
        if (!$this->inspector->isRecording()) {
            return;
        }

        if ($this->inspector->needTransaction()) {
            $this->inspector->startTransaction($workflow::class)
                ->setType('neuron-workflow')
                ->addContext('List', [
                    'nodes' => \array_keys($data->nodes),
                    'edges' => \array_map(fn (Edge $edge): array => [
                        'from' => $edge->getFrom(),
                        'to' => $edge->getTo(),
                        'has_condition' => $edge->hasCondition(),
                    ], $data->edges)
                ]);
        } elseif ($this->inspector->canAddSegments()) {
            $this->segments[$workflow::class] = $this->inspector->startSegment('neuron-workflow', $workflow::class)
                ->setColor(self::SEGMENT_COLOR);
        }
    }

    public function workflowEnd(\SplSubject $workflow, string $event, WorkflowEnd $data): void
    {
        if (\array_key_exists($workflow::class, $this->segments)) {
            $this->segments[$workflow::class]
                ->end()
                ->addContext('State', $data->state->all());
        } elseif ($this->inspector->canAddSegments()) {
            $transaction = $this->inspector->transaction();
            $transaction->addContext('State', $data->state->all());
            $transaction->setResult('success');
        }
    }

    public function workflowNodeStart(\SplSubject $workflow, string $event, WorkflowNodeStart $data): void
    {
        if (!$this->inspector->canAddSegments()) {
            return;
        }

        $segment = $this->inspector
            ->startSegment('workflow-node', $data->node)
            ->setColor(self::SEGMENT_COLOR);
        $segment->addContext('Before', $data->state->all());
        $this->segments[$data->node] = $segment;
    }

    public function workflowNodeEnd(\SplSubject $workflow, string $event, WorkflowNodeEnd $data): void
    {
        if (\array_key_exists($data->node, $this->segments)) {
            $segment = $this->segments[$data->node]->end();
            $segment->addContext('After', $data->state->all());
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Calculator;

use NeuronAI\Tools\ArrayProperty;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

class ModeTool extends Tool
{
    public function __construct()
    {
        parent::__construct(
            'calculate_mode',
            <<<DESC
Finds the mode(s) - the most frequently occurring value(s) in a dataset.
Returns all values that appear with the highest frequency. Use this tool to identify
the most common values, analyze categorical data converted to numbers, find typical
responses in surveys, or detect patterns in discrete data. Can return multiple modes
if several values tie for highest frequency.
DESC
        );
    }

    protected function properties(): array
    {
        return [
            new ArrayProperty(
                name: 'numbers',
                description: 'Array of numerical values',
                required: true,
                items: new ToolProperty(
                    'number',
                    PropertyType::NUMBER,
                    'A numerical value',
                    true,
                )
            ),
        ];
    }

    public function __invoke(array $numbers): array
    {
        // Validate input
        if ($numbers === []) {
            return ['error' => 'Data array cannot be empty'];
        }

        // Filter and validate numeric values
        $numericData = \array_filter($numbers, fn (string|int|float $value): bool => \is_numeric($value));

        if ($numericData === []) {
            return ['error' => 'Data array must contain at least one numeric value'];
        }

        // Convert to float values
        $numericData = \array_map('floatval', $numericData);

        // Count frequency of each value
        $frequencies = \array_count_values($numericData);
        $maxFrequency = \max($frequencies);

        // Find all values with maximum frequency
        $modes = \array_keys($frequencies, $maxFrequency);

        // Convert back to numeric values and sort
        $modes = \array_map('floatval', $modes);
        \sort($modes);

        return $modes;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\PostProcessor;

use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\RAG\Document;

class JinaRerankerPostProcessor implements PostProcessorInterface
{
    protected Client $client;

    public function __construct(
        protected string $key,
        protected string $model = 'jina-reranker-v2-base-multilingual',
        protected int $topN = 3
    ) {
    }

    protected function getClient(): Client
    {
        return $this->client ?? $this->client = new Client([
            'base_uri' => 'https://api.jina.ai/v1/',
            'headers' => [
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
                'Authorization' => 'Bearer '.$this->key,
            ],
        ]);
    }

    public function process(Message $question, array $documents): array
    {
        $response = $this->getClient()->post('rerank', [
            RequestOptions::JSON => [
                'model' => $this->model,
                'query' => $question->getContent(),
                'top_n' => $this->topN,
                'documents' => \array_map(fn (Document $document): array => ['text' => $document->getContent()], $documents),
                'return_documents' => false,
            ],
        ])->getBody()->getContents();

        $result = \json_decode($response, true);

        return \array_map(function (array $item) use ($documents): Document {
            $document = $documents[$item['index']];
            $document->setScore($item['relevance_score']);
            return $document;
        }, $result['results']);
    }

    public function setClient(Client $client): PostProcessorInterface
    {
        $this->client = $client;
        return $this;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Tavily;

use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\ToolProperty;
use NeuronAI\Tools\Tool;

/**
 * @method static static make(string $key, array $topics)
 */
class TavilySearchTool extends Tool
{
    protected Client $client;

    protected string $url = 'https://api.tavily.com/';

    protected array $options = [
        'search_depth' => 'basic',
        'chunks_per_source' => 3,
        'max_results' => 1,
    ];

    /**
     * @param string $key Tavily API key.
     * @param array $topics Explicit the topics you want to force the Agent to perform web search.
     */
    public function __construct(
        protected string $key,
        protected array $topics = [],
    ) {

        parent::__construct(
            'web_search',
            'Use this tool to search the web for additional information '.
            ($this->topics === [] ? '' : 'about '.\implode(', ', $this->topics).', or ').
            'if the question is outside the scope of the context you have.'
        );

    }

    protected function properties(): array
    {
        return [
            new ToolProperty(
                'search_query',
                PropertyType::STRING,
                'The search query to perform web search.',
                true
            ),
            new ToolProperty(
                'topic',
                PropertyType::STRING,
                'Explicit the topic you want to perform the web search on.',
                false,
                ['general', 'news']
            ),
            new ToolProperty(
                'time_range',
                PropertyType::STRING,
                '',
                false,
                ['day, week, month, year']
            ),
            new ToolProperty(
                'days',
                PropertyType::INTEGER,
                'Filter search results for a certain range of days up to today.',
                false,
            )
        ];
    }

    protected function getClient(): Client
    {
        return $this->client ?? $this->client = new Client([
            'base_uri' => \trim($this->url, '/').'/',
            'headers' => [
                'Authorization' => 'Bearer '.$this->key,
                'Content-Type' => 'application/json',
                'Accept' => 'application/json',
            ]
        ]);
    }

    public function __invoke(
        string $search_query,
        ?string $topic = null,
        ?string $time_range = null,
        ?int $days = null,
    ): array {
        $topic ??= 'general';
        $time_range ??= 'day';
        $days ??= 7;

        $result = $this->getClient()->post('search', [
            RequestOptions::JSON => \array_merge(
                ['topic' => $topic, 'time_range' => $time_range, 'days' => $days],
                $this->options,
                [
                    'query' => $search_query,
                ]
            )
        ])->getBody()->getContents();

        $result = \json_decode($result, true);

        return [
            'answer' => $result['answer'],
            'results' => \array_map(fn (array $item): array => [
                'title' => $item['title'],
                'url' => $item['url'],
                'content' => $item['content'],
            ], $result['results']),
        ];
    }

    public function withOptions(array $options): self
    {
        $this->options = $options;
        return $this;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore\Doctrine;

use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;

class VectorType extends Type
{
    final public const VECTOR = 'vector';

    /**
     * @param  mixed[]  $column
     */
    public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
    {
        $platformClass = $platform::class;

        $parts = \explode('\\', $platformClass);
        $shortName = \end($parts); // e.g., 'PostgreSQLPlatform'

        $shortName =  \strtolower(\str_replace('Platform', '', $shortName)); // e.g., 'postgresql'

        if (! \in_array($shortName, SupportedDoctrineVectorStore::values())) {
            throw Exception::notSupported('VECTORs not supported by Platform. ' . $shortName);
        }

        if (! isset($column['length'])) {
            throw Exception::notSupported('VECTORs must have a length.');
        }

        if ($column['length'] < 1) {
            throw Exception::notSupported('VECTORs must have a length greater than 0.');
        }

        if (! \is_int($column['length'])) {
            throw Exception::notSupported('VECTORs must have a length that is an integer.');
        }

        return \sprintf('vector(%d)', $column['length']);
    }

    /**
     * @return float[]
     */
    public function convertToPHPValue(mixed $value, AbstractPlatform $platform): array
    {
        if ($value === null) {
            return [];
        }

        $value = \is_resource($value) ? \stream_get_contents($value) : $value;

        if (! \is_string($value)) {
            throw Exception::notSupported('Error while converting VECTORs to PHP value.');
        }

        $convertedValue = \explode(',', $value);
        $floatArray = [];
        foreach ($convertedValue as $singleConvertedValue) {
            $floatArray[] = (float) $singleConvertedValue;
        }

        return $floatArray;
    }

    public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): string
    {
        //If $value is not a float array throw an exception
        if (! \is_array($value)) {
            throw Exception::notSupported('VECTORs must be an array.');
        }

        return SupportedDoctrineVectorStore::fromPlatform($platform)->getVectorAsString($value);
    }

    public function convertToDatabaseValueSQL(mixed $sqlExpression, AbstractPlatform $platform): string
    {
        return SupportedDoctrineVectorStore::fromPlatform($platform)->convertToDatabaseValueSQL($sqlExpression);
    }

    public function canRequireSQLConversion(): bool
    {
        return true;
    }

    public function getName(): string
    {
        return self::VECTOR;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\MySQL;

use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;
use PDO;

/**
 * @method static static make(PDO $pdo)
 */
class MySQLSelectTool extends Tool
{
    protected array $allowedStatements = ['SELECT', 'WITH', 'SHOW', 'DESCRIBE', 'EXPLAIN'];

    protected array $forbiddenStatements = [
        'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER',
        'TRUNCATE', 'REPLACE', 'MERGE', 'CALL', 'EXECUTE'
    ];

    public function __construct(protected PDO $pdo)
    {
        parent::__construct(
            'execute_select_query',
            'Use this tool only to run SELECT query against the MySQL database.
This the tool to use only to gather information from the MySQL database.'
        );
    }

    protected function properties(): array
    {
        return [
            new ToolProperty(
                'query',
                PropertyType::STRING,
                'The SELECT query you want to run against the database.',
                true
            )
        ];
    }

    public function __invoke(string $query): string|array
    {
        if (!$this->validateReadOnly($query)) {
            return "The query was rejected for security reasons.
            It looks like you are trying to run a write query using the read-only query tool.";
        }

        $stmt = $this->pdo->prepare($query);
        $stmt->execute();
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    protected function validateReadOnly(string $query): bool
    {
        // Remove comments and normalize whitespace
        $cleanQuery = $this->sanitizeQuery($query);

        // Check if it starts with allowed statements
        $firstKeyword = $this->getFirstKeyword($cleanQuery);
        if (!\in_array($firstKeyword, $this->allowedStatements)) {
            return false;
        }

        // Check for forbidden keywords that might be in subqueries
        foreach ($this->forbiddenStatements as $forbidden) {
            if (self::containsKeyword($cleanQuery, $forbidden)) {
                return false;
            }
        }

        return true;
    }

    protected function sanitizeQuery(string $query): string
    {
        // Remove SQL comments
        $query = \preg_replace('/--.*$/m', '', $query);
        $query = \preg_replace('/\/\*.*?\*\//s', '', (string) $query);

        // Normalize whitespace
        return \preg_replace('/\s+/', ' ', \trim((string) $query));
    }

    protected function getFirstKeyword(string $query): string
    {
        if (\preg_match('/^\s*(\w+)/i', $query, $matches)) {
            return \strtoupper($matches[1]);
        }
        return '';
    }

    protected function containsKeyword(string $query, string $keyword): bool
    {
        // Use word boundaries to avoid false positives
        return \preg_match('/\b' . \preg_quote($keyword, '/') . '\b/i', $query) === 1;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class IsNull extends AbstractValidationRule
{
    protected string $message = '{name} must be null';

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if ($value !== null) {
            $violations[] = $this->buildMessage($name, $this->message);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

use NeuronAI\StructuredOutput\Validation\ValidationRuleInterface;

abstract class AbstractValidationRule implements ValidationRuleInterface
{
    protected function buildMessage(string $name, string $messageTemplate, array $vars = []): string
    {
        foreach ($vars as $key => $value) {
            $messageTemplate = \str_replace('{'.$key.'}', (string) $value, $messageTemplate);
        }

        return \str_replace('{name}', $name, $messageTemplate);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability;

use NeuronAI\AgentInterface;
use NeuronAI\Observability\Events\PostProcessed;
use NeuronAI\Observability\Events\PostProcessing;
use NeuronAI\Observability\Events\PreProcessed;
use NeuronAI\Observability\Events\PreProcessing;
use NeuronAI\Observability\Events\Retrieved;
use NeuronAI\Observability\Events\Retrieving;

trait HandleRagEvents
{
    public function ragRetrieving(AgentInterface $agent, string $event, Retrieving $data): void
    {
        if (!$this->inspector->canAddSegments()) {
            return;
        }

        $id = \md5($data->question->getContent().$data->question->getRole());

        $this->segments[$id] = $this->inspector
            ->startSegment(self::SEGMENT_TYPE.'-retrieval', "vector_retrieval( {$data->question->getContent()} )")
            ->setColor(self::SEGMENT_COLOR);
    }

    public function ragRetrieved(AgentInterface $agent, string $event, Retrieved $data): void
    {
        $id = \md5($data->question->getContent().$data->question->getRole());

        if (\array_key_exists($id, $this->segments)) {
            $segment = $this->segments[$id];
            $segment->addContext('Data', [
                    'question' => $data->question->getContent(),
                    'documents' => \count($data->documents)
                ]);
            $segment->end();
        }
    }

    public function preProcessing(AgentInterface $agent, string $event, PreProcessing $data): void
    {
        if (!$this->inspector->canAddSegments()) {
            return;
        }

        $segment = $this->inspector
            ->startSegment(self::SEGMENT_TYPE.'-preprocessing', $data->processor)
            ->setColor(self::SEGMENT_COLOR);

        $segment->addContext('Original', $data->original->jsonSerialize());

        $this->segments[$data->processor] = $segment;
    }

    public function preProcessed(AgentInterface $agent, string $event, PreProcessed $data): void
    {
        if (\array_key_exists($data->processor, $this->segments)) {
            $this->segments[$data->processor]
                ->end()
                ->addContext('Processed', $data->processed->jsonSerialize());
        }
    }

    public function postProcessing(AgentInterface $agent, string $event, PostProcessing $data): void
    {
        if (!$this->inspector->canAddSegments()) {
            return;
        }

        $segment = $this->inspector
            ->startSegment(self::SEGMENT_TYPE.'-postprocessing', $data->processor)
            ->setColor(self::SEGMENT_COLOR);

        $segment->addContext('Question', $data->question->jsonSerialize())
            ->addContext('Documents', $data->documents);

        $this->segments[$data->processor] = $segment;
    }

    public function postProcessed(AgentInterface $agent, string $event, PostProcessed $data): void
    {
        if (\array_key_exists($data->processor, $this->segments)) {
            $this->segments[$data->processor]
                ->end()
                ->addContext('PostProcess', $data->documents);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore\Doctrine;

use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManagerInterface;
use NeuronAI\Exceptions\VectorStoreException;
use NeuronAI\RAG\Document;
use NeuronAI\RAG\VectorStore\VectorStoreInterface;

class DoctrineVectorStore implements VectorStoreInterface
{
    protected array $filters = [];

    private readonly SupportedDoctrineVectorStore $doctrineVectorStoreType;
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        public readonly string $entityClassName,
        protected int $topK = 4,
    ) {
        if (!\interface_exists(EntityManagerInterface::class)) {
            throw new \RuntimeException('To use this functionality, you must install the `doctrine/orm` package: `composer require doctrine/orm`.');
        }

        $conn = $entityManager->getConnection();
        $this->doctrineVectorStoreType = SupportedDoctrineVectorStore::fromPlatform($conn->getDatabasePlatform());
        $registeredTypes = Type::getTypesMap();
        if (!\array_key_exists(VectorType::VECTOR, $registeredTypes)) {
            Type::addType(VectorType::VECTOR, VectorType::class);
            $conn->getDatabasePlatform()->registerDoctrineTypeMapping('vector', VectorType::VECTOR);
        }

        $this->doctrineVectorStoreType->addCustomisationsTo($this->entityManager);
    }

    public function addDocument(Document $document): void
    {
        if ($document->embedding === []) {
            throw new \RuntimeException('document embedding must be set before adding a document');
        }

        $this->persistDocument($document);
        $this->entityManager->flush();
    }

    public function addDocuments(array $documents): void
    {
        if ($documents === []) {
            return;
        }
        foreach ($documents as $document) {
            $this->persistDocument($document);
        }

        $this->entityManager->flush();
    }

    public function deleteBySource(string $sourceType, string $sourceName): void
    {
        throw new VectorStoreException("Delete by source not implemented in ".self::class);
    }

    public function similaritySearch(array $embedding): array
    {
        $repository = $this->entityManager->getRepository($this->entityClassName);

        $qb = $repository
            ->createQueryBuilder('e')
            ->orderBy($this->doctrineVectorStoreType->l2DistanceName().'(e.embedding, :embeddingString)', 'ASC')
            ->setParameter('embeddingString', $this->doctrineVectorStoreType->getVectorAsString($embedding))
            ->setMaxResults($this->topK);

        foreach ($this->filters as $key => $value) {
            $paramName = 'where_'.$key;
            $qb->andWhere(\sprintf('e.%s = :%s', $key, $paramName))
                ->setParameter($paramName, $value);
        }

        /** @var DoctrineEmbeddingEntityBase[] */
        return $qb->getQuery()->getResult();
    }

    private function persistDocument(Document $document): void
    {
        if ($document->embedding === []) {
            throw new \RuntimeException('Trying to save a document in a vectorStore without embedding');
        }

        if (!$document instanceof DoctrineEmbeddingEntityBase) {
            throw new \RuntimeException('Document needs to be an instance of DoctrineEmbeddingEntityBase');
        }

        $this->entityManager->persist($document);
    }

    /**
     * @return iterable<Document>
     */
    public function fetchDocumentsByChunkRange(string $sourceType, string $sourceName, int $leftIndex, int $rightIndex): iterable
    {
        $repository = $this->entityManager->getRepository($this->entityClassName);

        $query = $repository->createQueryBuilder('d')
            ->andWhere('d.sourceType = :sourceType')
            ->andWhere('d.sourceName = :sourceName')
            ->andWhere('d.chunkNumber >= :lower')
            ->andWhere('d.chunkNumber <= :upper')
            ->setParameter('sourceType', $sourceType)
            ->setParameter('sourceName', $sourceName)
            ->setParameter('lower', $leftIndex)
            ->setParameter('upper', $rightIndex)
            ->getQuery();

        return $query->toIterable();
    }

    public function withFilters(array $filters): VectorStoreInterface
    {
        $this->filters = $filters;
        return $this;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class IsTrue extends AbstractValidationRule
{
    protected string $message = '{name} must be true';

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if ($value !== true) {
            $violations[] = $this->buildMessage($name, $this->message);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class IsFalse extends AbstractValidationRule
{
    protected string $message = '{name} must be false';

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if ($value !== false) {
            $violations[] = $this->buildMessage($name, $this->message);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class IsNotNull extends AbstractValidationRule
{
    protected string $message = '{name} must not be null';

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if ($value === null) {
            $violations[] = $this->buildMessage($name, $this->message);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class Url extends AbstractValidationRule
{
    protected string $message = '{name} must be a valid URL';

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if (\filter_var($value, \FILTER_VALIDATE_URL) === false) {
            $violations[] = $this->buildMessage($name, $this->message);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class Email extends AbstractValidationRule
{
    protected string $message = '{name} must be a valid email address';

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if (\filter_var($value, \FILTER_VALIDATE_EMAIL) === false) {
            $violations[] = $this->buildMessage($name, $this->message);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class IPAddress extends AbstractValidationRule
{
    protected string $message = '{name} must be a valid IP address';

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if (\filter_var($value, \FILTER_VALIDATE_IP) === false) {
            $violations[] = $this->buildMessage($name, $this->message);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Tavily;

use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use NeuronAI\Exceptions\ToolException;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\ToolProperty;
use NeuronAI\Tools\Tool;

/**
 * @method static static make(string $key)
 */
class TavilyExtractTool extends Tool
{
    protected Client $client;

    protected string $url = 'https://api.tavily.com/';

    protected array $options = [];

    /**
     * @param string $key Tavily API key.
     */
    public function __construct(protected string $key)
    {
        parent::__construct(
            'url_reader',
            'Get the content of a URL in markdown format.'
        );
    }

    protected function properties(): array
    {
        return [
            new ToolProperty(
                'url',
                PropertyType::STRING,
                'The URL to read.',
                true
            ),
        ];
    }

    public function __invoke(string $url): string
    {
        if (!\filter_var($url, \FILTER_VALIDATE_URL)) {
            throw new ToolException('Invalid URL.');
        }

        $result = $this->getClient()->post('extract', [
            RequestOptions::JSON => \array_merge(
                $this->options,
                ['urls' => [$url]]
            )
        ])->getBody()->getContents();

        $result = \json_decode($result, true);

        return $result['results'][0];
    }

    protected function getClient(): Client
    {
        return $this->client ?? $this->client = new Client([
            'base_uri' => \trim($this->url, '/').'/',
            'headers' => [
                'Authorization' => 'Bearer '.$this->key,
                'Content-Type' => 'application/json',
                'Accept' => 'application/json',
            ]
        ]);
    }

    public function withOptions(array $options): self
    {
        $this->options = $options;
        return $this;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Workflow\Persistence;

use NeuronAI\Exceptions\WorkflowException;
use NeuronAI\Workflow\WorkflowInterrupt;
use NeuronAI\Workflow\WorkflowState;

class FilePersistence implements PersistenceInterface
{
    public function __construct(
        protected string $directory,
        protected string $prefix = 'neuron_workflow_',
        protected string $ext = '.store'
    ) {
        if (!\is_dir($this->directory)) {
            throw new WorkflowException("Directory '{$this->directory}' does not exist");
        }
    }

    public function save(string $workflowId, WorkflowInterrupt $interrupt): void
    {
        \file_put_contents($this->getFilePath($workflowId), \json_encode($interrupt));
    }

    public function load(string $workflowId): WorkflowInterrupt
    {
        if (!\is_file($this->getFilePath($workflowId))) {
            throw new WorkflowException("No saved workflow found for ID: {$workflowId}.");
        }

        $interrupt = \json_decode(\file_get_contents($this->getFilePath($workflowId)), true) ?? [];

        return new WorkflowInterrupt(
            $interrupt['data'],
            $interrupt['currentNode'],
            new WorkflowState($interrupt['state'])
        );
    }

    public function delete(string $workflowId): void
    {
        if (\file_exists($this->getFilePath($workflowId))) {
            \unlink($this->getFilePath($workflowId));
        }
    }

    protected function getFilePath(string $workflowId): string
    {
        return $this->directory.\DIRECTORY_SEPARATOR.$this->prefix.$workflowId.$this->ext;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability;

use SplObserver;

trait Observable
{
    /**
     * @var array<string, SplObserver[]>
     */
    private array $observers = [];

    private function initEventGroup(string $event = '*'): void
    {
        /*
         * If developers attach an observer, the agent monitoring will not be attached by default.
         */
        if (!isset($this->observers['*']) && !empty($_ENV['INSPECTOR_INGESTION_KEY'])) {
            $this->observers['*'] = [
                AgentMonitoring::instance(),
            ];
        }

        if (!isset($this->observers[$event])) {
            $this->observers[$event] = [];
        }
    }

    private function getEventObservers(string $event = "*"): array
    {
        $this->initEventGroup($event);
        $group = $this->observers[$event];
        $all = $this->observers["*"] ?? [];

        return \array_merge($group, $all);
    }

    public function observe(SplObserver $observer, string $event = "*"): self
    {
        $this->attach($observer, $event);
        return $this;
    }

    public function attach(SplObserver $observer, string $event = "*"): void
    {
        $this->initEventGroup($event);
        $this->observers[$event][] = $observer;
    }

    public function detach(SplObserver $observer, string $event = "*"): void
    {
        foreach ($this->getEventObservers($event) as $key => $s) {
            if ($s === $observer) {
                unset($this->observers[$event][$key]);
            }
        }
    }

    public function notify(string $event = "*", mixed $data = null): void
    {
        // Broadcasting the '$event' event";
        foreach ($this->getEventObservers($event) as $observer) {
            $observer->update($this, $event, $data);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Tavily;

use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use NeuronAI\Exceptions\ToolException;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\ToolProperty;
use NeuronAI\Tools\Tool;

/**
 * @method static static make(string $key)
 */
class TavilyCrawlTool extends Tool
{
    protected Client $client;

    protected string $url = 'https://api.tavily.com/';

    protected array $options = [
        'include_images' => false,
        'allow_external' => false
    ];

    /**
     * @param string $key Tavily API key.
     */
    public function __construct(
        protected string $key,
    ) {
        parent::__construct(
            'url_crawl',
            'Get the entire website in markdown format.'
        );
    }

    protected function properties(): array
    {
        return [
            new ToolProperty(
                'url',
                PropertyType::STRING,
                'The URL to crawl.',
                true
            ),
        ];
    }

    public function __invoke(string $url): array
    {
        if (!\filter_var($url, \FILTER_VALIDATE_URL)) {
            throw new ToolException('Invalid URL.');
        }

        $result = $this->getClient()->post('crawl', [
            RequestOptions::JSON => \array_merge(
                $this->options,
                ['url' => $url]
            )
        ])->getBody()->getContents();

        return \json_decode($result, true);
    }

    protected function getClient(): Client
    {
        return $this->client ?? $this->client = new Client([
            'base_uri' => \trim($this->url, '/').'/',
            'headers' => [
                'Authorization' => 'Bearer '.$this->key,
                'Content-Type' => 'application/json',
                'Accept' => 'application/json',
            ]
        ]);
    }

    public function withOptions(array $options): self
    {
        $this->options = $options;
        return $this;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\Embeddings;

use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;

class GeminiEmbeddingsProvider extends AbstractEmbeddingsProvider
{
    protected Client $client;

    protected string $baseUri = 'https://generativelanguage.googleapis.com/v1beta/models/';

    public function __construct(
        protected string $key,
        protected string $model,
        protected array $config = []
    ) {
        $this->client = new Client([
            'headers' => [
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
                'x-goog-api-key' => $this->key,
            ]
        ]);
    }

    public function embedText(string $text): array
    {
        $response = $this->client->post(\trim($this->baseUri, '/')."/{$this->model}:embedContent", [
            RequestOptions::JSON => [
                'content' => [
                    'parts' => [['text' => $text]]
                ],
                ...($this->config !== [] ? ['embedding_config' => $this->config] : []),
            ]
        ])->getBody()->getContents();

        $response = \json_decode($response, true);

        return $response['embedding']['values'];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Calculator;

use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

class DivideTool extends Tool
{
    public function __construct()
    {
        parent::__construct(
            'divide',
            'Calculate the division between two numbers and return the result'
        );
    }

    public function properties(): array
    {
        return [
            ToolProperty::make(
                name: 'number1',
                type: PropertyType::NUMBER,
                description: 'The numerator of the division',
                required: true,
            ),
            ToolProperty::make(
                name: 'number2',
                type: PropertyType::NUMBER,
                description: 'The denominator of the division',
                required: true,
            )
        ];
    }

    public function __invoke(int|float $number1, int|float $number2): int|float|array
    {
        if (\floatval($number2) === 0.0) {
            return [
                'operation' => $this->name,
                'error' => 'Division by zero is not allowed.'
            ];
        }

        return $number1 / $number2;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG;

class Document implements \JsonSerializable
{
    public string|int $id;

    public array $embedding = [];

    public string $sourceType = 'manual';

    public string $sourceName = 'manual';

    public float $score = 0;

    public array $metadata = [];

    public function __construct(
        public string $content = '',
    ) {
        $this->id = \uniqid();
    }

    public function getId(): string|int
    {
        return $this->id;
    }

    public function getContent(): string
    {
        return $this->content;
    }

    public function getEmbedding(): array
    {
        return $this->embedding;
    }

    public function getSourceType(): string
    {
        return $this->sourceType;
    }

    public function getSourceName(): string
    {
        return $this->sourceName;
    }

    public function getScore(): float
    {
        return $this->score;
    }

    public function setScore(float $score): Document
    {
        $this->score = $score;
        return $this;
    }

    public function addMetadata(string $key, string|int $value): Document
    {
        $this->metadata[$key] = $value;
        return $this;
    }

    public function jsonSerialize(): array
    {
        return [
            'id' => $this->getId(),
            'content' => $this->getContent(),
            'embedding' => $this->getEmbedding(),
            'sourceType' => $this->getSourceType(),
            'sourceName' => $this->getSourceName(),
            'score' => $this->getScore(),
            'metadata' => $this->metadata,
        ];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\PreProcessor;

use NeuronAI\Chat\Messages\Message;
use NeuronAI\Chat\Messages\UserMessage;
use NeuronAI\Providers\AIProviderInterface;
use NeuronAI\SystemPrompt;

class QueryTransformationPreProcessor implements PreProcessorInterface
{
    /**
     * Creates a new query transformation preprocessor.
     *
     * @param AIProviderInterface $provider The AI provider for query transformation
     * @param QueryTransformationType $transformation The transformation strategy
     * @param string|null $customPrompt Custom system prompt to override built-in transformations strategies
     */
    public function __construct(
        protected AIProviderInterface     $provider,
        protected QueryTransformationType $transformation = QueryTransformationType::REWRITING,
        protected ?string                 $customPrompt = null,
    ) {
    }

    /**
     * Transforms a user query for optimized RAG document retrieval.
     *
     * Applies the configured transformation strategy or uses custom system prompt
     * if provided to enhance query effectiveness.
     *
     * @param Message $question The original user queries to transform
     * @return Message The transformed query optimized for document retrieval
     */
    public function process(Message $question): Message
    {
        $preparedMessage = $this->prepareMessage($question);

        return $this->provider
            ->systemPrompt($this->getSystemPrompt())
            ->chat([$preparedMessage]);
    }

    public function getSystemPrompt(): string
    {
        return $this->customPrompt ?? match ($this->transformation) {
            QueryTransformationType::REWRITING => $this->getRewritingPrompt(),
            QueryTransformationType::DECOMPOSITION => $this->getDecompositionPrompt(),
            QueryTransformationType::HYDE => $this->getHydePrompt(),
        };
    }

    public function setCustomPrompt(string $prompt): self
    {
        $this->customPrompt = $prompt;
        return $this;
    }

    public function setTransformation(QueryTransformationType $transformation): self
    {
        $this->transformation = $transformation;
        return $this;
    }

    public function setProvider(AIProviderInterface $provider): self
    {
        $this->provider = $provider;
        return $this;
    }

    protected function prepareMessage(Message $question): UserMessage
    {
        return new UserMessage('<ORIGINAL-QUERY>' . $question->getContent() . '</ORIGINAL-QUERY>');
    }

    protected function getRewritingPrompt(): string
    {
        return (string) new SystemPrompt(
            background: [
                'You are an AI assistant tasked with reformulating user queries to improve retrieval in a RAG system.'
            ],
            steps: [
                'Given the original query, rewrite it to be more specific, detailed, and likely to retrieve relevant information.',
                'Focus on expanding vocabulary and technical terminology while preserving the original intent and meaning.',
            ],
            output: [
                'Output only the reformulated query',
                'Do not add temporal references, dates, or years unless they are present in the original query'
            ]
        );
    }

    protected function getDecompositionPrompt(): string
    {
        return (string) new SystemPrompt(
            background: [
                'You are an AI assistant that breaks down complex queries into simpler sub-queries for comprehensive information retrieval in a RAG system.'
            ],
            steps: [
                'Given the original complex query, decompose it into focused sub-queries.',
                'Each sub-query should address a specific aspect of the original question.',
                'Ensure all sub-queries together cover the full scope of the original query.',
            ],
            output: [
                'Output each sub-query on a separate line',
                'Use clear, specific language for each sub-query',
                'Do not add temporal references, dates, or years unless they are present in the original query',
            ]
        );
    }

    protected function getHydePrompt(): string
    {
        return (string) new SystemPrompt(
            background: [
                'You are an AI assistant that generates hypothetical answer to the user query, to improve retrieval in a RAG system.'
            ],
            steps: [
                'Given the original query, write a hypothetical document passage that would directly answer this question.',
                'Create content that resembles what you would expect to find in a relevant document.',
            ],
            output: [
                'Output only the hypothetical document passage',
                'Do not add temporal references, dates, or years unless they are present in the original query',
                'Keep the response as concise as possible'
            ]
        );
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\OpenAI;

use NeuronAI\Chat\Messages\Message;

trait HandleStructured
{
    public function structured(
        array $messages,
        string $class,
        array $response_format
    ): Message {
        $tk = \explode('\\', $class);

        $this->parameters = \array_merge($this->parameters, [
            'response_format' => [
                'type' => 'json_schema',
                'json_schema' => [
                    "name" => \end($tk),
                    "strict" => false,
                    "schema" => $response_format,
                ],
            ]
        ]);

        return $this->chat($messages);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI;

class SystemPrompt implements \Stringable
{
    public function __construct(
        public array $background,
        public array $steps = [],
        public array $output = [],
        public array $toolsUsage = []
    ) {
    }

    public function __toString(): string
    {
        $prompt = "# IDENTITY AND PURPOSE" . \PHP_EOL . \implode(\PHP_EOL, $this->background);

        if ($this->steps !== []) {
            $prompt .= \PHP_EOL . \PHP_EOL . "# INTERNAL ASSISTANT STEPS" . \PHP_EOL . \implode(\PHP_EOL, $this->steps);
        }

        if ($this->output !== []) {
            $prompt .= \PHP_EOL . \PHP_EOL . "# OUTPUT INSTRUCTIONS" . \PHP_EOL . " - " . \implode(\PHP_EOL . " - ", $this->output);
        }

        if ($this->toolsUsage !== []) {
            $prompt .= \PHP_EOL . \PHP_EOL . "# TOOLS USAGE RULES" . \PHP_EOL . " - " . \implode(\PHP_EOL . " - ", $this->toolsUsage);
        }

        return $prompt;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\PostProcessor;

use NeuronAI\Chat\Messages\Message;
use NeuronAI\RAG\Document;

class FixedThresholdPostProcessor implements PostProcessorInterface
{
    /**
     * Creates a post-processor that filters documents based on a fixed threshold.
     *
     * @param float $threshold threshold value (documents with scores below this value will be filtered out)
     */
    public function __construct(
        /**
         * The threshold value below which documents will be filtered out.
         */
        protected float $threshold = 0.5
    ) {
    }

    public function process(Message $question, array $documents): array
    {
        return \array_values(\array_filter($documents, fn (Document $document): bool => $document->getScore() >= $this->threshold));
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\Embeddings;

use GuzzleHttp\Client;

class OpenAIEmbeddingsProvider extends AbstractEmbeddingsProvider
{
    protected Client $client;

    protected string $baseUri = 'https://api.openai.com/v1/embeddings';

    public function __construct(
        protected string $key,
        protected string $model,
        protected int $dimensions = 1024
    ) {
        $this->client = new Client([
            'base_uri' => $this->baseUri,
            'headers' => [
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
                'Authorization' => 'Bearer ' . $this->key,
            ]
        ]);
    }

    public function embedText(string $text): array
    {
        $response = $this->client->post('', [
            'json' => [
                'model' => $this->model,
                'input' => $text,
                'encoding_format' => 'float',
                'dimensions' => $this->dimensions,
            ]
        ])->getBody()->getContents();

        $response = \json_decode($response, true);

        return $response['data'][0]['embedding'];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Riza;

use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\ToolProperty;
use NeuronAI\Tools\Tool;

/**
 * @method static static make(string $pdo, string $language = 'PHP')
 */
class RizaCodeInterpreter extends Tool
{
    protected Client $client;

    protected string $url = 'https://api.riza.io/v1/';

    public function __construct(
        protected string $key,
        protected string $language = 'PHP',
    ) {
        parent::__construct(
            "execute_{$language}_code",
            "Execute {$language} scripts in a secure and isolated runtime environment."
        );
    }

    protected function properties(): array
    {
        return [
            new ToolProperty(
                'code',
                PropertyType::STRING,
                'The code to execute.',
                true,
            ),
            new ToolProperty(
                'args',
                PropertyType::ARRAY,
                "List of command line arguments to pass to the script (List of strings).",
                false,
            ),
            new ToolProperty(
                'env',
                PropertyType::ARRAY,
                "Set of key-value pairs to add to the script's execution environment.",
                false,
            )
        ];
    }

    public function getClient(): Client
    {
        return $this->client ?? $this->client = new Client([
            'base_uri' => \trim($this->url, '/').'/',
            'headers' => [
                'Authorization' => 'Bearer '.$this->key,
                'Content-Type' => 'application/json; charset=utf-8',
                'Accept' => 'application/json',
            ]
        ]);
    }

    public function __invoke(
        string $code,
        array $args = [],
        array $env = [],
    ): mixed {
        $result = $this->getClient()->post('execute', [
            RequestOptions::JSON => [
                'language' => $this->language,
                'code' => $code,
                'args' => $args,
                'env' => $env,
            ]
        ])->getBody()->getContents();

        return \json_decode($result, true);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Riza;

use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

/**
 * @method static static make(string $pdo, string $language = 'JavaScript')
 */
class RizaFunctionExecutor extends Tool
{
    protected Client $client;

    protected string $url = 'https://api.riza.io/v1/';

    public function __construct(
        protected string $key,
        protected string $language = 'JavaScript', // Python, JavaScript, and TypeScript (no PHP unfortunately)
    ) {
        parent::__construct(
            "execute_{$language}_function",
            "Execute {$language} function and get the result."
        );

    }

    protected function properties(): array
    {
        return [
            new ToolProperty(
                'code',
                PropertyType::STRING,
                'The function code to execute.',
                true,
            ),
            new ToolProperty(
                'input',
                PropertyType::ARRAY,
                'The input arguments to execute the function.',
                false,
            ),
            new ToolProperty(
                'env',
                PropertyType::ARRAY,
                "Set of key-value pairs to add to the script's execution environment.",
                false,
            )
        ];
    }

    protected function getClient(): Client
    {
        return $this->client ?? $this->client = new Client([
            'base_uri' => \trim($this->url, '/').'/',
            'headers' => [
                'Authorization' => 'Bearer '.$this->key,
                'Content-Type' => 'application/json; charset=utf-8',
                'Accept' => 'application/json',
            ]
        ]);
    }

    public function __invoke(
        string $code,
        array $input = [],
        array $env = [],
    ): mixed {
        $result = $this->getClient()->post('execute-function', [
            RequestOptions::JSON => [
                'language' => $this->language,
                'code' => $code,
                'input' => $input,
                'env' => $env,
            ]
        ])->getBody()->getContents();

        return \json_decode($result, true);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Calculator;

use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

class NthRootTool extends Tool
{
    public function __construct()
    {
        parent::__construct(
            'calculate_nth_root',
            <<<DESC
Calculates the nth root of a number (e.g., square root, cube root, fourth root, etc.). Use this tool when you need to
find what number, when raised to the power of n, equals the given input. For example,
the 3rd root (cube root) of 8 is 2, because 2³ = 8. This tool handles any positive root
degree and works with both positive and negative numbers (though negative numbers only work with odd roots).
Common use cases include volume calculations (cube roots), higher-order geometric problems, financial calculations
involving compound growth rates, and advanced mathematical computations.
DESC
        );
    }

    protected function properties(): array
    {
        return [
            new ToolProperty(
                name: 'number',
                type: PropertyType::NUMBER,
                description: 'The number to calculate the nth root of',
                required: true,
            ),
            new ToolProperty(
                name: 'root_degree',
                type: PropertyType::INTEGER,
                description: 'The degree of the root (e.g., 2 for square root, 3 for cube root, 4 for fourth root, etc.). Must be a positive number.',
                required: true,
            )
        ];
    }

    public function __invoke(float|int $number, int $root_degree): float|int
    {
        return $number ** (1 / $root_degree);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools;

use NeuronAI\Exceptions\ToolException;

enum PropertyType: string
{
    case INTEGER = 'integer';
    case STRING = 'string';
    case NUMBER = 'number';
    case BOOLEAN = 'boolean';
    case ARRAY = 'array';
    case OBJECT = 'object';

    /**
     * Get the scalar type of the property from the array syntax like ["string", "null"]
     *
     * @throws ToolException
     */
    public static function fromSchema(array|string $schema): PropertyType
    {
        if (\is_string($schema)) {
            return PropertyType::from($schema);
        }

        foreach ($schema as $type) {
            try {
                return PropertyType::from($type);
            } catch (\Throwable) {
            }
        }

        throw new ToolException("Property type not valid.");
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\Embeddings;

use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;

class OllamaEmbeddingsProvider extends AbstractEmbeddingsProvider
{
    protected Client $client;

    public function __construct(
        protected string $model,
        protected string $url = 'http://localhost:11434/api',
        protected array $parameters = [],
    ) {
        $this->client = new Client(['base_uri' => \trim($this->url, '/').'/']);
    }

    public function embedText(string $text): array
    {
        $response = $this->client->post('embed', [
            RequestOptions::JSON => [
                'model' => $this->model,
                'input' => $text,
                ...$this->parameters,
            ]
        ])->getBody()->getContents();

        $response = \json_decode($response, true);

        return $response['embeddings'][0];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Jina;

use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use NeuronAI\Exceptions\ToolException;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\ToolProperty;
use NeuronAI\Tools\Tool;

/**
 * @method static make(string $key)
 */
class JinaUrlReader extends Tool
{
    protected Client $client;

    public function __construct(protected string $key)
    {
        parent::__construct(
            'url_reader',
            'Get the content of a URL in markdown format.'
        );
    }

    protected function properties(): array
    {
        return [
            new ToolProperty(
                'url',
                PropertyType::STRING,
                'The URL to read.',
                true
            ),
        ];
    }

    protected function getClient(): Client
    {
        return $this->client ?? $this->client = new Client([
            'headers' => [
                'Authorization' => 'Bearer '.$this->key,
                'Content-Type' => 'application/json',
                'Accept' => 'application/json',
                'X-Return-Format' => 'Markdown',
            ]
        ]);
    }

    public function __invoke(string $url): string
    {
        if (!\filter_var($url, \FILTER_VALIDATE_URL)) {
            throw new ToolException('Invalid URL.');
        }

        return $this->getClient()->post('https://r.jina.ai/', [
            RequestOptions::JSON => [
                'url' => $url,
            ]
        ])->getBody()->getContents();
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Deepseek;

use NeuronAI\Chat\Messages\Message;
use NeuronAI\Providers\OpenAI\OpenAI;

class Deepseek extends OpenAI
{
    protected string $baseUri = "https://api.deepseek.com/v1";

    public function structured(
        array $messages,
        string $class,
        array $response_format
    ): Message {
        $this->parameters = \array_merge($this->parameters, [
            'response_format' => [
                'type' => 'json_object',
            ]
        ]);

        $this->system .= \PHP_EOL."# OUTPUT FORMAT CONSTRAINTS".\PHP_EOL
            .'Generate a json respecting this schema: '.\json_encode($response_format);

        return $this->chat($messages);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Zep;

use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;

trait HandleZepClient
{
    protected Client $client;

    protected string $url = 'https://api.getzep.com/api/v2';

    protected function getClient(): Client
    {
        return $this->client ?? $this->client = new Client([
            'base_uri' => \trim($this->url, '/').'/',
            'headers' => [
                'Authorization' => "Api-Key {$this->key}",
                'Content-Type' => 'application/json',
                'Accept' => 'application/json',
            ]
        ]);
    }

    protected function createUser(): self
    {
        // Create the user if it doesn't exist
        try {
            $this->getClient()->get('users/'.$this->user_id);
        } catch (\Exception) {
            $this->getClient()->post('users', [
                RequestOptions::JSON => ['user_id' => $this->user_id]
            ]);
        }

        return $this;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\Embeddings;

use NeuronAI\RAG\Document;

abstract class AbstractEmbeddingsProvider implements EmbeddingsProviderInterface
{
    public function embedDocuments(array $documents): array
    {
        foreach ($documents as $index => $document) {
            $documents[$index] = $this->embedDocument($document);
        }

        return $documents;
    }

    public function embedDocument(Document $document): Document
    {
        $text = $document->formattedContent ?? $document->content;
        $document->embedding = $this->embedText($text);

        return $document;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools;

use NeuronAI\StaticConstructor;

/**
 * @method static static make(string $name, PropertyType $type, string $description, bool $required = false, array $enum = [])
 */
class ToolProperty implements ToolPropertyInterface
{
    use StaticConstructor;

    public function __construct(
        protected string $name,
        protected PropertyType $type,
        protected ?string $description = null,
        protected bool $required = false,
        protected array $enum = [],
    ) {
    }

    public function jsonSerialize(): array
    {
        return [
            'name' => $this->name,
            'description' => $this->description,
            'type' => $this->type->value,
            'enum' => $this->enum,
            'required' => $this->required,
        ];
    }

    public function isRequired(): bool
    {
        return $this->required;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getType(): PropertyType
    {
        return $this->type;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function getEnum(): array
    {
        return $this->enum;
    }

    public function getJsonSchema(): array
    {
        $schema = [
            'type' => $this->type->value,
        ];

        if (!\is_null($this->description)) {
            $schema['description'] = $this->description;
        }

        if ($this->enum !== []) {
            $schema['enum'] = $this->enum;
        }

        return $schema;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Jina;

use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\ToolProperty;
use NeuronAI\Tools\Tool;

/**
 * @method static make(string $key, array $topics)
 */
class JinaWebSearch extends Tool
{
    protected Client $client;

    public function __construct(
        protected string $key,
        array $topics = [],
    ) {
        parent::__construct(
            'web_search',
            'Use this tool to search the web for additional information '.
            ($topics === [] ? '' : 'about '.\implode(', ', $topics).', or ').
            'if the question is outside the scope of the context you have.'
        );
    }

    protected function properties(): array
    {
        return [
            new ToolProperty(
                'search_query',
                PropertyType::STRING,
                'The search query to perform web search.',
                true
            )
        ];
    }

    protected function getClient(): Client
    {
        return $this->client ?? $this->client = new Client([
            'headers' => [
                'Authorization' => 'Bearer '.$this->key,
                'Content-Type' => 'application/json',
                'X-Respond-With' => 'no-content',
            ]
        ]);
    }

    public function __invoke(string $search_query): string
    {
        return $this->getClient()->post('https://s.jina.ai/', [
            RequestOptions::JSON => [
                'q' => $search_query,
            ]
        ])->getBody()->getContents();
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Workflow\Exporter;

use NeuronAI\Workflow\Workflow;
use ReflectionClass;

class MermaidExporter implements ExporterInterface
{
    public function export(Workflow $graph): string
    {
        $output = "graph TD\n";

        foreach ($graph->getEdges() as $edge) {
            $from = $this->getShortClassName($edge->getFrom());
            $to = $this->getShortClassName($edge->getTo());

            $output .= "    {$from} --> {$to}\n";
        }

        return $output;
    }

    private function getShortClassName(string $class): string
    {
        $reflection = new ReflectionClass($class);
        return $reflection->getShortName();
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\Splitter;

use NeuronAI\RAG\Document;

abstract class AbstractSplitter implements SplitterInterface
{
    /**
     * @param  Document[]  $documents
     * @return Document[]
     */
    public function splitDocuments(array $documents): array
    {
        $split = [];

        foreach ($documents as $document) {
            $split = \array_merge($split, $this->splitDocument($document));
        }

        return $split;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class EqualTo extends AbstractValidationRule
{
    public function __construct(protected mixed $reference)
    {
    }

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if ($value !== $this->reference) {
            $violations[] = $this->buildMessage($name, 'must be equal to {compare}', ['compare' => \get_debug_type($this->reference)]);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\History;

use NeuronAI\Chat\Messages\Message;

interface ChatHistoryInterface extends \JsonSerializable
{
    public function addMessage(Message $message): ChatHistoryInterface;

    /**
     * @return array<Message>
     */
    public function getMessages(): array;

    public function getLastMessage(): Message|false;

    public function setMessages(array $messages): ChatHistoryInterface;

    public function flushAll(): ChatHistoryInterface;

    public function calculateTotalUsage(): int;
}

<?php

declare(strict_types=1);

namespace NeuronAI;

use GuzzleHttp\Promise\PromiseInterface;
use NeuronAI\Chat\History\AbstractChatHistory;
use NeuronAI\Chat\History\ChatHistoryInterface;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Providers\AIProviderInterface;
use NeuronAI\Tools\ToolInterface;
use NeuronAI\Tools\Toolkits\ToolkitInterface;

interface AgentInterface extends \SplSubject
{
    public function withProvider(AIProviderInterface $provider): AgentInterface;

    public function resolveProvider(): AIProviderInterface;

    public function withInstructions(string $instructions): AgentInterface;

    public function instructions(): string;

    public function addTool(ToolInterface|ToolkitInterface|array $tools): AgentInterface;

    public function getTools(): array;

    public function withChatHistory(AbstractChatHistory $chatHistory): AgentInterface;

    public function resolveChatHistory(): ChatHistoryInterface;

    public function observe(\SplObserver $observer, string $event = "*"): self;

    public function chat(Message|array $messages): Message;

    public function chatAsync(Message|array $messages): PromiseInterface;

    public function stream(Message|array $messages): \Generator;

    public function structured(Message|array $messages, ?string $class = null, int $maxRetries = 1): mixed;
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\Attachments;

use NeuronAI\Chat\Enums\AttachmentContentType;
use NeuronAI\Chat\Enums\AttachmentType;
use NeuronAI\StaticConstructor;

class Attachment implements \JsonSerializable
{
    use StaticConstructor;

    public function __construct(
        public AttachmentType $type,
        public string $content,
        public AttachmentContentType $contentType = AttachmentContentType::URL,
        public ?string $mediaType = null
    ) {
        //
    }

    public function jsonSerialize(): array
    {
        return \array_filter([
            'type' => $this->type->value,
            'content' => $this->content,
            'content_type' => $this->contentType->value,
            'media_type' => $this->mediaType,
        ]);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class GreaterThan extends AbstractValidationRule
{
    public function __construct(protected mixed $reference)
    {
    }

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if (\is_null($this->reference) || $value <= $this->reference) {
            $violations[] = $this->buildMessage($name, 'must be greater than {compare}', ['compare' => \get_debug_type($this->reference)]);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\MySQL;

use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;
use PDO;

/**
 * @method static static make(PDO $pdo)
 */
class MySQLWriteTool extends Tool
{
    public function __construct(protected PDO $pdo)
    {
        parent::__construct(
            'execute_write_query',
            'Use this tool to perform write operations against the MySQL database (e.g. INSERT, UPDATE, DELETE).'
        );
    }

    protected function properties(): array
    {
        return [
            new ToolProperty(
                'query',
                PropertyType::STRING,
                'The write query you want to run against the MySQL database.',
                true
            )
        ];
    }

    public function __invoke(string $query): string
    {
        $result = $this->pdo->prepare($query)->execute();

        return $result
            ? "The query has been executed successfully."
            : "I'm sorry, there was an error executing the query.";
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class GreaterThanEqual extends AbstractValidationRule
{
    public function __construct(protected mixed $reference)
    {
    }

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if (\is_null($this->reference) || $value < $this->reference) {
            $violations[] = $this->buildMessage($name, 'must be greater than {compare}', ['compare' => \get_debug_type($this->reference)]);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\PGSQL;

use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;
use PDO;

/**
 * @method static static make(PDO $pdo)
 */
class PGSQLWriteTool extends Tool
{
    public function __construct(protected PDO $pdo)
    {
        parent::__construct(
            'execute_write_query',
            'Use this tool to perform write operations against the PostgreSQL database (e.g. INSERT, UPDATE, DELETE).'
        );
    }

    protected function properties(): array
    {
        return [
            new ToolProperty(
                'query',
                PropertyType::STRING,
                'The write query you want to run against the PostgreSQL database.',
                true
            )
        ];
    }

    public function __invoke(string $query): string
    {
        $result = $this->pdo->prepare($query)->execute();

        return $result
            ? "The query has been executed successfully."
            : "I'm sorry, there was an error executing the query.";
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class LowerThan extends AbstractValidationRule
{
    public function __construct(protected mixed $reference)
    {
    }

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if (\is_null($value) || $value >= $this->reference) {
            $violations[] = $this->buildMessage($name, 'must be greater than {compare}', ['compare' => \get_debug_type($this->reference)]);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class LowerThanEqual extends AbstractValidationRule
{
    public function __construct(protected mixed $reference)
    {
    }

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if (\is_null($value) || $value > $this->reference) {
            $violations[] = $this->buildMessage($name, 'must be greater than {compare}', ['compare' => \get_debug_type($this->reference)]);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation\Rules;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
class NotEqualTo extends AbstractValidationRule
{
    public function __construct(protected mixed $reference)
    {
    }

    public function validate(string $name, mixed $value, array &$violations): void
    {
        if ($value === $this->reference) {
            $violations[] = $this->buildMessage($name, 'must not be equal to {compare}', ['compare' => \get_debug_type($this->reference)]);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore\Doctrine;

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\ORM\EntityManagerInterface;

abstract class SupportedDoctrineVectorStore
{
    /**
     * @param  float[]  $vector
     */
    abstract public function getVectorAsString(array $vector): string;

    abstract public function convertToDatabaseValueSQL(string $sqlExpression): string;

    abstract public function addCustomisationsTo(EntityManagerInterface $entityManager): void;

    abstract public function l2DistanceName(): string;

    /**
     * @param  float[]  $vector
     */
    protected function stringListOf(array $vector): string
    {
        return \implode(',', $vector);
    }

    /**
     * @return string[]
     */
    public static function values(): array
    {
        return [
            'postgresql',
            'postgresql120',
            'mysql',
        ];
    }

    public static function fromPlatform(AbstractPlatform $platform): self
    {
        if (\str_starts_with($platform::class, 'Doctrine\DBAL\Platforms\MariaDb')) {
            return new MariaDBVectorStoreType();
        }
        if (\str_starts_with($platform::class, 'Doctrine\DBAL\Platforms\PostgreSQL')) {
            return new PostgresqlVectorStoreType();
        }

        throw new \RuntimeException('Unsupported DoctrineVectorStore type');
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Calculator;

use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

class SquareRootTool extends Tool
{
    public function __construct()
    {
        parent::__construct(
            name: 'calculate_square_root',
            description: <<<DESC
Calculates the square root of a positive number. Use this tool when you need to find the square root
of any positive number, whether it's for mathematical calculations, geometric problems, or statistical computations.
The input must be a non-negative number (zero or positive).
This tool is particularly useful for calculations involving areas, distances, standard deviations,
or any scenario where you need to find what number, when multiplied by itself, equals the given input.
DESC
        );
    }

    protected function properties(): array
    {
        return [
            new ToolProperty(
                name: 'number',
                type: PropertyType::NUMBER,
                description: 'The positive number to calculate the square root of',
                required: true,
            )
        ];
    }

    public function __invoke(float|int $number): float|int
    {
        return \sqrt($number);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\Messages;

class Usage implements \JsonSerializable
{
    public function __construct(
        public int $inputTokens,
        public int $outputTokens
    ) {
    }

    public function getTotal(): int
    {
        return $this->inputTokens + $this->outputTokens;
    }

    public function jsonSerialize(): array
    {
        return [
            'input_tokens' => $this->inputTokens,
            'output_tokens' => $this->outputTokens,
        ];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\HuggingFace;

use NeuronAI\Providers\OpenAI\OpenAI;

class HuggingFace extends OpenAI
{
    protected string $baseUri = 'https://router.huggingface.co/%s/v1';

    public function __construct(
        protected string            $key,
        protected string            $model,
        protected ?InferenceProvider $inferenceProvider = InferenceProvider::HF_INFERENCE,
        protected array             $parameters = [],
    ) {
        $this->buildBaseUri();
        parent::__construct($key, $model, $parameters);
    }

    private function buildBaseUri(): void
    {
        $endpoint = match ($this->inferenceProvider) {
            InferenceProvider::HF_INFERENCE => \trim($this->inferenceProvider->value, \DIRECTORY_SEPARATOR).\DIRECTORY_SEPARATOR.$this->model,
            default => \trim($this->inferenceProvider->value, \DIRECTORY_SEPARATOR),
        };

        $this->baseUri = \sprintf($this->baseUri, $endpoint);
    }

}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Anthropic;

use NeuronAI\Chat\Messages\Message;

trait HandleStructured
{
    public function structured(
        array $messages,
        string $class,
        array $response_format
    ): Message {
        $this->system .= \PHP_EOL."# OUTPUT CONSTRAINTS".\PHP_EOL.
            "Your response should be a JSON string following this schema: ".\PHP_EOL.
            \json_encode($response_format);

        return $this->chat($messages);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\History;

class InMemoryChatHistory extends AbstractChatHistory
{
    public function __construct(int $contextWindow = 50000)
    {
        parent::__construct($contextWindow);
    }

    public function setMessages(array $messages): ChatHistoryInterface
    {
        return $this;
    }

    protected function clear(): ChatHistoryInterface
    {
        return $this;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\OpenAI;

use GuzzleHttp\Client;

class AzureOpenAI extends OpenAI
{
    protected string $baseUri = "https://%s/openai/deployments/%s";

    public function __construct(
        protected string $key,
        protected string $endpoint,
        protected string $model,
        protected string $version,
        protected array $parameters = [],
    ) {
        $this->setBaseUrl();

        $this->client = new Client([
            'base_uri' => $this->baseUri,
            'query'    => [
                'api-version' => $this->version,
            ],
            'headers' => [
                'Authorization' => 'Bearer '.$this->key,
                'Accept' => 'application/json',
                'Content-Type' => 'application/json',
            ],
        ]);
    }

    private function setBaseUrl(): void
    {
        $this->endpoint = \preg_replace('/^https?:\/\/([^\/]*)\/?$/', '$1', $this->endpoint);
        $this->baseUri = \sprintf($this->baseUri, $this->endpoint, $this->model);
        $this->baseUri = \trim($this->baseUri, '/').'/';
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Workflow;

use NeuronAI\Workflow\Persistence\PersistenceInterface;

class WorkflowContext
{
    protected bool $isResuming = false;
    protected array $feedback = [];

    public function __construct(
        protected string $workflowId,
        protected string $currentNode,
        protected PersistenceInterface $persistence,
        protected WorkflowState $currentState
    ) {
    }

    public function interrupt(array $data): mixed
    {
        if ($this->isResuming && isset($this->feedback[$this->currentNode])) {
            return $this->feedback[$this->currentNode];
        }

        throw new WorkflowInterrupt($data, $this->currentNode, $this->currentState);
    }

    public function setResuming(bool $resuming, array $feedback = []): void
    {
        $this->isResuming = $resuming;
        $this->feedback = $feedback;
    }

    public function setCurrentState(WorkflowState $state): WorkflowContext
    {
        $this->currentState = $state;
        return $this;
    }

    public function setCurrentNode(string $node): WorkflowContext
    {
        $this->currentNode = $node;
        return $this;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI;

use NeuronAI\Observability\Observable;

class Agent implements AgentInterface
{
    use StaticConstructor;
    use ResolveProvider;
    use ResolveTools;
    use ResolveChatHistory;
    use HandleChat;
    use HandleStream;
    use HandleStructured;
    use Observable;

    /**
     * The system instructions.
     */
    protected string $instructions;

    public function withInstructions(string $instructions): AgentInterface
    {
        $this->instructions = $instructions;
        return $this;
    }

    public function instructions(): string
    {
        return 'Your are a helpful and friendly AI agent built with NeuronAI PHP framework.';
    }

    public function resolveInstructions(): string
    {
        return $this->instructions ?? $this->instructions();
    }

    protected function removeDelimitedContent(string $text, string $openTag, string $closeTag): string
    {
        // Escape special regex characters in the tags
        $escapedOpenTag = \preg_quote($openTag, '/');
        $escapedCloseTag = \preg_quote($closeTag, '/');

        // Create the regex pattern to match content between tags
        $pattern = '/' . $escapedOpenTag . '.*?' . $escapedCloseTag . '/s';

        // Remove all occurrences of the delimited content
        return \preg_replace($pattern, '', $text);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\DataLoader;

use NeuronAI\RAG\Splitter\DelimiterTextSplitter;
use NeuronAI\RAG\Splitter\SplitterInterface;

abstract class AbstractDataLoader implements DataLoaderInterface
{
    protected SplitterInterface $splitter;

    public function __construct()
    {
        $this->splitter = new DelimiterTextSplitter(
            maxLength: 1000,
            separator: '.',
            wordOverlap: 0
        );
    }

    public static function for(...$arguments): static
    {
        /** @phpstan-ignore new.static */
        return new static(...$arguments);
    }

    public function withSplitter(SplitterInterface $splitter): DataLoaderInterface
    {
        $this->splitter = $splitter;
        return $this;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\Ollama;

use NeuronAI\Chat\Messages\Message;

trait HandleStructured
{
    public function structured(
        array $messages,
        string $class,
        array $response_format
    ): Message {
        $this->parameters = \array_merge($this->parameters, [
            'format' => $response_format,
        ]);

        return $this->chat($messages);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\Messages;

use NeuronAI\Chat\Enums\MessageRole;

/**
 * @method static static make(array|string|int|float|null $content = null)
 */
class UserMessage extends Message
{
    public function __construct(array|string|int|float|null $content)
    {
        parent::__construct(MessageRole::USER, $content);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\Messages;

use NeuronAI\Chat\Enums\MessageRole;

/**
 * @method static static make(array|string|int|float|null $content = null)
 */
class AssistantMessage extends Message
{
    public function __construct(array|string|int|float|null $content)
    {
        parent::__construct(MessageRole::ASSISTANT, $content);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore\Doctrine;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use NeuronAI\RAG\Document;

#[ORM\MappedSuperclass]
class DoctrineEmbeddingEntityBase extends Document
{
    #[ORM\Id]
    #[ORM\Column(name: 'id', type: 'integer')]
    #[ORM\GeneratedValue(strategy: 'AUTO')]
    public string|int $id;

    #[ORM\Column(type: VectorType::VECTOR, length: 3072)]
    public array $embedding = [];

    #[ORM\Column(type: Types::TEXT)]
    public string $content;

    #[ORM\Column(type: Types::TEXT)]
    public string $sourceType = 'manual';

    #[ORM\Column(type: Types::TEXT)]
    public string $sourceName = 'manual';

    #[ORM\Column(type: Types::INTEGER)]
    public int $chunkNumber = 0;

    public function getId(): string|int
    {
        return $this->id;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Workflow;

class Edge
{
    public function __construct(
        protected string $from,
        protected string $to,
        protected ?\Closure $condition = null
    ) {
    }

    public function getFrom(): string
    {
        return $this->from;
    }

    public function getTo(): string
    {
        return $this->to;
    }

    public function hasCondition(): bool
    {
        return $this->condition instanceof \Closure;
    }

    public function shouldExecute(WorkflowState $state): bool
    {
        return $this->hasCondition() ? ($this->condition)($state) : true;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG;

use NeuronAI\RAG\VectorStore\VectorStoreInterface;

trait ResolveVectorStore
{
    /**
     * The vector store of the RAG system.
     */
    protected VectorStoreInterface $store;

    public function setVectorStore(VectorStoreInterface $store): RAG
    {
        $this->store = $store;
        return $this;
    }

    protected function vectorStore(): VectorStoreInterface
    {
        return $this->store;
    }

    public function resolveVectorStore(): VectorStoreInterface
    {
        if (!isset($this->store)) {
            $this->store = $this->vectorStore();
        }
        return $this->store;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Workflow;

use NeuronAI\Exceptions\WorkflowException;

abstract class Node implements NodeInterface
{
    protected WorkflowContext $context;

    public function setContext(WorkflowContext $context): void
    {
        $this->context = $context;
    }

    protected function interrupt(array $data): mixed
    {
        if (!isset($this->context)) {
            throw new WorkflowException('WorkflowContext not set on node');
        }

        return $this->context->interrupt($data);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\History;

use NeuronAI\Chat\Messages\Message;

interface TokenCounterInterface
{
    /**
     * @param Message[] $messages
     */
    public function count(array $messages): int;
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore\Doctrine;

use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Parser;

abstract class AbstractDBL2OperatorDql extends FunctionNode
{
    protected Node $vectorTwo;

    protected Node $vectorOne;

    public function parse(Parser $parser): void
    {
        if (\class_exists(\Doctrine\ORM\Query\TokenType::class)) {
            $parser->match(\Doctrine\ORM\Query\TokenType::T_IDENTIFIER);
            $parser->match(\Doctrine\ORM\Query\TokenType::T_OPEN_PARENTHESIS);
        } else {
            $parser->match(\Doctrine\ORM\Query\Lexer::T_IDENTIFIER);
            $parser->match(\Doctrine\ORM\Query\Lexer::T_OPEN_PARENTHESIS);
        }

        $this->vectorOne = $parser->ArithmeticFactor(); // Fix that, should be vector

        if (\class_exists(\Doctrine\ORM\Query\TokenType::class)) {
            $parser->match(\Doctrine\ORM\Query\TokenType::T_COMMA);
        } else {
            $parser->match(\Doctrine\ORM\Query\Lexer::T_COMMA);
        }

        $this->vectorTwo = $parser->ArithmeticFactor(); // Fix that, should be vector

        if (\class_exists(\Doctrine\ORM\Query\TokenType::class)) {
            $parser->match(\Doctrine\ORM\Query\TokenType::T_CLOSE_PARENTHESIS);
        } else {
            $parser->match(\Doctrine\ORM\Query\Lexer::T_CLOSE_PARENTHESIS);
        }
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI;

use NeuronAI\Providers\AIProviderInterface;

trait ResolveProvider
{
    /**
     * The AI provider instance.
     */
    protected AIProviderInterface $provider;

    /**
     * @deprecated
     */
    public function withProvider(AIProviderInterface $provider): AgentInterface
    {
        $this->provider = $provider;
        return $this;
    }

    public function setAiProvider(AIProviderInterface $provider): AgentInterface
    {
        $this->provider = $provider;
        return $this;
    }

    protected function provider(): AIProviderInterface
    {
        return $this->provider;
    }

    /**
     * Get the current instance of the chat history.
     */
    public function resolveProvider(): AIProviderInterface
    {
        if (!isset($this->provider)) {
            $this->provider = $this->provider();
        }

        return $this->provider;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers;

use GuzzleHttp\Client;
use GuzzleHttp\Promise\PromiseInterface;
use NeuronAI\Chat\Messages\Message;
use NeuronAI\Tools\ToolInterface;

interface AIProviderInterface
{
    /**
     * Send predefined instruction to the LLM.
     */
    public function systemPrompt(?string $prompt): AIProviderInterface;

    /**
     * Set the tools to be exposed to the LLM.
     *
     * @param ToolInterface[] $tools
     */
    public function setTools(array $tools): AIProviderInterface;

    /**
     * The component responsible for mapping the NeuronAI Message to the AI provider format.
     */
    public function messageMapper(): MessageMapperInterface;

    /**
     * Send a prompt to the AI agent.
     *
     * @param Message[] $messages
     */
    public function chat(array $messages): Message;

    /**
     * Send a prompt to the AI agent.
     *
     * @param Message[] $messages
     */
    public function chatAsync(array $messages): PromiseInterface;

    /**
     * @param Message[]|string $messages
     */
    public function stream(array|string $messages, callable $executeToolsCallback): \Generator;

    /**
     * @param Message[] $messages
     */
    public function structured(array $messages, string $class, array $response_schema): Message;

    public function setClient(Client $client): AIProviderInterface;
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG;

use NeuronAI\RAG\Embeddings\EmbeddingsProviderInterface;

trait ResolveEmbeddingProvider
{
    /**
     * The embeddings provider of the RAG system.
     */
    protected EmbeddingsProviderInterface $embeddingsProvider;

    public function setEmbeddingsProvider(EmbeddingsProviderInterface $provider): RAG
    {
        $this->embeddingsProvider = $provider;
        return $this;
    }

    protected function embeddings(): EmbeddingsProviderInterface
    {
        return $this->embeddingsProvider;
    }

    public function resolveEmbeddingsProvider(): EmbeddingsProviderInterface
    {
        if (!isset($this->embeddingsProvider)) {
            $this->embeddingsProvider = $this->embeddings();
        }
        return $this->embeddingsProvider;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Calculator;

use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

class SumTool extends Tool
{
    public function __construct()
    {
        parent::__construct(
            name: 'sum',
            description: 'Calculate the sum between two numbers and return the result',
        );
    }

    protected function properties(): array
    {
        return [
            ToolProperty::make(
                name: 'number1',
                type: PropertyType::NUMBER,
                description: 'First addend',
                required: true,
            ),
            ToolProperty::make(
                name: 'number2',
                type: PropertyType::NUMBER,
                description: 'Second addend',
                required: true,
            )
        ];
    }

    public function __invoke(int|float $number1, int|float $number2): int|float
    {
        return $number1 + $number2;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Chat\Messages\Message;

class Extracted
{
    public function __construct(public Message $message, public array $schema, public ?string $json)
    {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Calculator;

use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

class MultiplyTool extends Tool
{
    public function __construct()
    {
        parent::__construct(
            name: 'multiply',
            description: 'Calculate the multiplication between two numbers and return the result',
        );
    }

    public function properties(): array
    {
        return [
            ToolProperty::make(
                name: 'number1',
                type: PropertyType::NUMBER,
                description: 'First factor',
                required: true,
            ),
            ToolProperty::make(
                name: 'number2',
                type: PropertyType::NUMBER,
                description: 'Second factor',
                required: true,
            )
        ];
    }

    public function __invoke(int|float $number1, int|float $number2): int|float
    {
        return $number1 * $number2;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Calculator;

use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

class ExponentialTool extends Tool
{
    public function __construct()
    {
        parent::__construct(
            name: 'calculate_exponential',
            description: 'Calculate the exponential between two numbers and return the result',
        );
    }

    public function properties(): array
    {
        return [
            ToolProperty::make(
                name: 'number',
                type: PropertyType::NUMBER,
                description: 'The base number to exponentiate',
                required: true,
            ),
            ToolProperty::make(
                name: 'exponent',
                type: PropertyType::NUMBER,
                description: 'The exponent',
                required: true,
            )
        ];
    }

    public function __invoke(int|float $number, int $exponent): int|float
    {
        return $number ** $exponent;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Calculator;

use NeuronAI\Tools\PropertyType;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;

class SubtractTool extends Tool
{
    public function __construct()
    {
        parent::__construct(
            name: 'substract',
            description: 'Calculate the subtraction between two numbers and return the result',
        );
    }

    public function properties(): array
    {
        return [
            ToolProperty::make(
                name: 'number1',
                type: PropertyType::NUMBER,
                description: 'Minuend number',
                required: true,
            ),
            ToolProperty::make(
                name: 'number2',
                type: PropertyType::NUMBER,
                description: 'Subtrahend number',
                required: true,
            )
        ];
    }

    public function __invoke(int|float $number1, int|float $number2): int|float
    {
        return $number1 - $number2;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Workflow;

class WorkflowState
{
    public function __construct(protected array $data = [])
    {
    }

    public function set(string $key, mixed $value): void
    {
        $this->data[$key] = $value;
    }

    public function get(string $key, mixed $default = null): mixed
    {
        return $this->data[$key] ?? $default;
    }

    public function has(string $key): bool
    {
        return \array_key_exists($key, $this->data);
    }

    /**
     * Missing keys in the state are simply ignored.
     *
     * @param string[] $keys
     */
    public function only(array $keys): array
    {
        return \array_intersect_key($this->data, \array_flip($keys));
    }

    public function all(): array
    {
        return $this->data;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Chat\Messages\Message;

class InferenceStop
{
    public function __construct(
        public Message|false $message,
        public Message $response
    ) {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\DataLoader;

use NeuronAI\RAG\Document;

class StringDataLoader extends AbstractDataLoader
{
    public function __construct(protected string $content)
    {
        parent::__construct();
    }

    public function getDocuments(): array
    {
        return $this->splitter->splitDocument(new Document($this->content));
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Zep;

use NeuronAI\Tools\Toolkits\AbstractToolkit;

/**
 * @method static make(string $key, string $user_id)
 */
class ZepLongTermMemoryToolkit extends AbstractToolkit
{
    public function __construct(
        protected string $key,
        protected string $user_id,
    ) {
    }

    public function provide(): array
    {
        return [
            ZepSearchGraphTool::make($this->key, $this->user_id),
            ZepAddToGraphTool::make($this->key, $this->user_id),
        ];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Workflow;

use NeuronAI\Exceptions\WorkflowException;

class WorkflowInterrupt extends WorkflowException implements \JsonSerializable
{
    public function __construct(
        protected array $data,
        protected string $currentNode,
        protected WorkflowState $state
    ) {
        parent::__construct('Workflow interrupted for human input');
    }

    public function getData(): array
    {
        return $this->data;
    }

    public function getCurrentNode(): string
    {
        return $this->currentNode;
    }

    public function getState(): WorkflowState
    {
        return $this->state;
    }

    public function jsonSerialize(): array
    {
        return [
            'data' => $this->data,
            'currentNode' => $this->currentNode,
            'state' => $this->state->all(),
        ];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Chat\Messages\Message;

class MessageSaving
{
    public function __construct(public Message $message)
    {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Chat\Messages\Message;

class MessageSaved
{
    public function __construct(public Message $message)
    {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Chat\Messages\Message;

class Extracting
{
    public function __construct(public Message $message)
    {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Chat\Messages\Message;

class InferenceStart
{
    public function __construct(public Message|false $message)
    {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Tavily;

use NeuronAI\Tools\Tool;
use NeuronAI\Tools\Toolkits\AbstractToolkit;

/**
 * @method static make(string $key)
 */
class TavilyToolkit extends AbstractToolkit
{
    public function __construct(protected string $key)
    {
    }

    /**
     * @return array<Tool>
     */
    public function provide(): array
    {
        return [
            new TavilyExtractTool($this->key),
            new TavilySearchTool($this->key),
            new TavilyCrawlTool($this->key)
        ];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\MySQL;

use NeuronAI\Tools\Toolkits\AbstractToolkit;
use PDO;

/**
 * @method static make(PDO $pdo)
 */
class MySQLToolkit extends AbstractToolkit
{
    public function __construct(protected PDO $pdo)
    {
    }

    public function guidelines(): ?string
    {
        return "These tools allow you to learn the database structure,
        getting detailed information about tables, columns, relationships, and constraints
        to generate and execute precise and efficient SQL queries.";
    }

    public function provide(): array
    {
        return [
            MySQLSchemaTool::make($this->pdo),
            MySQLSelectTool::make($this->pdo),
            MySQLWriteTool::make($this->pdo),
        ];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\PGSQL;

use NeuronAI\Tools\Toolkits\AbstractToolkit;
use PDO;

/**
 * @method static make(Pdo $pdo)
 */
class PGSQLToolkit extends AbstractToolkit
{
    public function __construct(protected PDO $pdo)
    {
    }

    public function guidelines(): ?string
    {
        return "These tools allow you to learn the database structure,
        getting detailed information about tables, columns, relationships, and constraints
        to generate and execute precise and efficient SQL queries.";
    }

    public function provide(): array
    {
        return [
            PGSQLSchemaTool::make($this->pdo),
            PGSQLSelectTool::make($this->pdo),
            PGSQLWriteTool::make($this->pdo),
        ];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Riza;

use NeuronAI\Tools\Tool;
use NeuronAI\Tools\Toolkits\AbstractToolkit;

/**
 * @method static make(string $key)
 */
class RizaToolkit extends AbstractToolkit
{
    public function __construct(protected string $key)
    {
    }

    /**
     * @return array<Tool>
     */
    public function provide(): array
    {
        return [
            new RizaCodeInterpreter($this->key),
            new RizaFunctionExecutor($this->key),
        ];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Jina;

use NeuronAI\Tools\Tool;
use NeuronAI\Tools\Toolkits\AbstractToolkit;

/**
 * @method static make(string $key)
 */
class JinaToolkit extends AbstractToolkit
{
    public function __construct(protected string $key)
    {
    }

    /**
     * @return array<Tool>
     */
    public function provide(): array
    {
        return [
            new JinaWebSearch($this->key),
            new JinaUrlReader($this->key),
        ];
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore\Doctrine;

use Doctrine\ORM\EntityManagerInterface;

class MariaDBVectorStoreType extends SupportedDoctrineVectorStore
{
    public function getVectorAsString(array $vector): string
    {
        return '['.$this->stringListOf($vector).']';
    }

    public function convertToDatabaseValueSQL(string $sqlExpression): string
    {
        return \sprintf('Vec_FromText(%s)', $sqlExpression);
    }

    public function addCustomisationsTo(EntityManagerInterface $entityManager): void
    {
        $entityManager->getConfiguration()->addCustomStringFunction($this->l2DistanceName(), MariaDBVectorL2OperatorDql::class);
    }

    public function l2DistanceName(): string
    {
        return 'VEC_DISTANCE_EUCLIDEAN';
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\Attachments;

use NeuronAI\Chat\Enums\AttachmentContentType;
use NeuronAI\Chat\Enums\AttachmentType;

class Document extends Attachment
{
    public function __construct(
        string $document,
        AttachmentContentType $type = AttachmentContentType::URL,
        ?string $mediaType = null
    ) {
        parent::__construct(
            AttachmentType::DOCUMENT,
            $document,
            $type,
            $mediaType
        );
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\Attachments;

use NeuronAI\Chat\Enums\AttachmentContentType;
use NeuronAI\Chat\Enums\AttachmentType;

class Image extends Attachment
{
    public function __construct(
        string $image,
        AttachmentContentType $type = AttachmentContentType::URL,
        ?string $mediaType = null
    ) {
        parent::__construct(
            AttachmentType::IMAGE,
            $image,
            $type,
            $mediaType
        );
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore\Doctrine;

use Doctrine\ORM\EntityManagerInterface;

class PostgresqlVectorStoreType extends SupportedDoctrineVectorStore
{
    public function getVectorAsString(array $vector): string
    {
        return '['.$this->stringListOf($vector).']';
    }

    public function convertToDatabaseValueSQL(string $sqlExpression): string
    {
        return $sqlExpression;
    }

    public function addCustomisationsTo(EntityManagerInterface $entityManager): void
    {
        $entityManager->getConfiguration()->addCustomStringFunction($this->l2DistanceName(), PostgresqlVectorL2OperatorDql::class);
    }

    public function l2DistanceName(): string
    {
        return 'VEC_DISTANCE_EUCLIDEAN';
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Workflow\Persistence;

use NeuronAI\Exceptions\WorkflowException;
use NeuronAI\Workflow\WorkflowInterrupt;

class InMemoryPersistence implements PersistenceInterface
{
    private array $storage = [];

    public function save(string $workflowId, WorkflowInterrupt $interrupt): void
    {
        $this->storage[$workflowId] = $interrupt;
    }

    public function load(string $workflowId): WorkflowInterrupt
    {
        return $this->storage[$workflowId] ?? throw new WorkflowException("No saved workflow found for ID: {$workflowId}");
    }

    public function delete(string $workflowId): void
    {
        unset($this->storage[$workflowId]);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Tools\ToolInterface;

class ToolCalled
{
    public function __construct(public ToolInterface $tool)
    {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Tools\ToolInterface;

class ToolCalling
{
    public function __construct(public ToolInterface $tool)
    {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\DataLoader;

class TextFileReader implements ReaderInterface
{
    public static function getText(string $filePath, array $options = []): string
    {
        return \file_get_contents($filePath);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools;

interface ToolInterface extends \JsonSerializable
{
    /**
     * Get the unique name of the tool.
     */
    public function getName(): string;

    /**
     * Get a description of the tool's functionality.
     */
    public function getDescription(): ?string;

    /**
     * Add a Property with a name, type, description, and optional required constraint.
     */
    public function addProperty(ToolPropertyInterface $property): ToolInterface;

    /**
     * Get the Properties schema.
     */
    public function getProperties(): array;

    /**
     * Names of the required properties.
     *
     * @return array<string>
     */
    public function getRequiredProperties(): array;

    /**
     * Define the code to be executed.
     */
    public function setCallable(callable $callback): ToolInterface;

    /**
     * Get the input arguments of the function call.
     */
    public function getInputs(): array;

    /**
     * Get the input arguments of the function call.
     */
    public function setInputs(array $inputs): ToolInterface;

    /**
     * The call identifier generated by the LLM.
     * @return string
     */
    public function getCallId(): ?string;

    public function setCallId(string $callId): ToolInterface;

    public function getResult(): string;

    /**
     * Execute the tool's logic with input parameters.
     */
    public function execute(): void;
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits;

use NeuronAI\Tools\ToolInterface;

interface ToolkitInterface
{
    public function guidelines(): ?string;

    /**
     * @return ToolInterface[]
     */
    public function tools(): array;

    /**
     * @param  class-string[]  $classes
     */
    public function exclude(array $classes): ToolkitInterface;

    /**
     * @param  class-string[]  $classes
     */
    public function only(array $classes): ToolkitInterface;

}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools\Toolkits\Calculator;

use NeuronAI\Tools\Toolkits\AbstractToolkit;

class CalculatorToolkit extends AbstractToolkit
{
    public function guidelines(): ?string
    {
        return "This toolkit allows you to perform mathematical operations. You can also use this functions to solve
        mathematical expressions executing smaller operations step by step to calculate the final result.";
    }

    public function provide(): array
    {
        return [
            SumTool::make(),
            SubtractTool::make(),
            MultiplyTool::make(),
            DivideTool::make(),
            ExponentialTool::make(),
            SquareRootTool::make(),
            NthRootTool::make(),
            MeanTool::make(),
            MedianTool::make(),
            ModeTool::make(),
            StandardDeviationTool::make(),
            VarianceTool::make(),
        ];
    }

}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore\Doctrine;

use Doctrine\ORM\Query\SqlWalker;

/**
 * L2DistanceFunction ::= "VEC_DISTANCE_EUCLIDEAN" "(" VectorPrimary "," VectorPrimary ")"
 */
final class MariaDBVectorL2OperatorDql extends AbstractDBL2OperatorDql
{
    public function getSql(SqlWalker $sqlWalker): string
    {
        return 'VEC_DISTANCE_EUCLIDEAN('.
            $this->vectorOne->dispatch($sqlWalker).', '.
            'VEC_FROMTEXT('.
            $this->vectorTwo->dispatch($sqlWalker).
            ')'.
            ')';
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore\Doctrine;

use Doctrine\ORM\Query\SqlWalker;

/**
 * L2DistanceFunction ::= "L2_DISTANCE" "(" VectorPrimary "," VectorPrimary ")"
 */
final class PostgresqlVectorL2OperatorDql extends AbstractDBL2OperatorDql
{
    public function getSql(SqlWalker $sqlWalker): string
    {
        return 'L2_DISTANCE('.
            $this->vectorOne->dispatch($sqlWalker).', '.
            $this->vectorTwo->dispatch($sqlWalker).
            ')';
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

class ToolsBootstrapped
{
    public function __construct(public array $tools, public array $guidelines = [])
    {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers;

use GuzzleHttp\Client;

trait HasGuzzleClient
{
    protected Client $client;

    public function setClient(Client $client): AIProviderInterface
    {
        $this->client = $client;
        return $this;
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers;

use NeuronAI\Chat\Messages\Message;

interface MessageMapperInterface
{
    /**
     * @param array<Message> $messages
     */
    public function map(array $messages): array;
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Chat\Messages\Message;
use NeuronAI\RAG\Document;

class Retrieved
{
    /**
     * @param Document[] $documents
     */
    public function __construct(
        public Message $question,
        public array $documents,
    ) {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Chat\Messages\Message;

class PostProcessing
{
    public function __construct(
        public string $processor,
        public Message $question,
        public array $documents
    ) {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Chat\Messages\Message;

class PostProcessed
{
    public function __construct(
        public string $processor,
        public Message $question,
        public array $documents
    ) {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

class Validated
{
    /**
     * @param array<string> $violations
     */
    public function __construct(
        public string $class,
        public string $json,
        public array $violations = []
    ) {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Chat\Messages\Message;

class PreProcessed
{
    public function __construct(
        public string $processor,
        public Message $processed
    ) {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\VectorStore;

use NeuronAI\RAG\Document;

interface VectorStoreInterface
{
    public function addDocument(Document $document): void;

    /**
     * @param  Document[]  $documents
     */
    public function addDocuments(array $documents): void;

    public function deleteBySource(string $sourceType, string $sourceName): void;

    /**
     * Return docs most similar to the embedding.
     *
     * @param  float[]  $embedding
     * @return Document[]
     */
    public function similaritySearch(array $embedding): iterable;
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Chat\Messages\Message;

class Retrieving
{
    public function __construct(
        public Message $question
    ) {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

class AgentError
{
    public function __construct(
        public \Throwable $exception,
        public bool $unhandled = true
    ) {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput;

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY)]
class SchemaProperty
{
    public function __construct(
        public ?string $title = null,
        public ?string $description = null,
        public ?bool $required = null
    ) {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\PostProcessor;

use NeuronAI\Chat\Messages\Message;
use NeuronAI\RAG\Document;

interface PostProcessorInterface
{
    /**
     * Process an array of documents and return the processed documents.
     *
     * @param Message $question The question to process the documents for.
     * @param Document[] $documents The documents to process.
     * @return Document[] The processed documents.
     */
    public function process(Message $question, array $documents): array;
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\PreProcessor;

use NeuronAI\Chat\Messages\Message;

interface PreProcessorInterface
{
    /**
     * Process and return the question.
     *
     * @param Message $question The question to process.
     * @return Message The processed question.
     */
    public function process(Message $question): Message;
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Chat\Messages\Message;

class PreProcessing
{
    public function __construct(public string $processor, public Message $original)
    {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\Enums;

enum MessageRole: string
{
    case USER = 'user';
    case ASSISTANT = 'assistant';
    case MODEL = 'model';
    case TOOL = 'tool';
    case SYSTEM = 'system';
    case DEVELOPER = 'developer';
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\Splitter;

use NeuronAI\RAG\Document;

interface SplitterInterface
{
    /**
     * @return Document[]
     */
    public function splitDocument(Document $document): array;

    /**
     * @param  Document[]  $documents
     * @return Document[]
     */
    public function splitDocuments(array $documents): array;
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\Embeddings;

use NeuronAI\RAG\Document;

interface EmbeddingsProviderInterface
{
    /**
     * @return float[]
     */
    public function embedText(string $text): array;

    public function embedDocument(Document $document): Document;

    /**
     * @param Document[] $documents
     * @return Document[]
     */
    public function embedDocuments(array $documents): array;
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Workflow\Edge;
use NeuronAI\Workflow\NodeInterface;

class WorkflowStart
{
    /**
     * @param NodeInterface[] $nodes
     * @param Edge[] $edges
     */
    public function __construct(public array $nodes, public array $edges)
    {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Tools;

interface ToolPropertyInterface extends \JsonSerializable
{
    public function getName(): string;

    public function getType(): PropertyType;

    public function getDescription(): ?string;

    public function isRequired(): bool;

    public function getJsonSchema(): array;
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

class Validating
{
    public function __construct(public string $class, public string $json)
    {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Workflow\WorkflowState;

class WorkflowNodeStart
{
    public function __construct(
        public string $node,
        public WorkflowState $state,
    ) {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Workflow\WorkflowState;

class WorkflowNodeEnd
{
    public function __construct(
        public string $node,
        public WorkflowState $state
    ) {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

class InstructionsChanged
{
    public function __construct(
        public string $previous,
        public string $current
    ) {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\DataLoader;

interface ReaderInterface
{
    public static function getText(string $filePath, array $options = []): string;
}

<?php

declare(strict_types=1);

namespace NeuronAI\MCP;

interface McpTransportInterface
{
    public function connect(): void;
    public function send(array $data): void;
    public function receive(): array;
    public function disconnect(): void;
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

class InstructionsChanging
{
    public function __construct(
        public string $instructions
    ) {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Workflow\Persistence;

use NeuronAI\Workflow\WorkflowInterrupt;

interface PersistenceInterface
{
    public function save(string $workflowId, WorkflowInterrupt $interrupt): void;
    public function load(string $workflowId): WorkflowInterrupt;
    public function delete(string $workflowId): void;
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\Enums;

enum AttachmentContentType: string
{
    case URL = 'url';
    case BASE64 = 'base64';
}

<?php

declare(strict_types=1);

namespace NeuronAI\Workflow;

interface NodeInterface
{
    public function run(WorkflowState $state): WorkflowState;
    public function setContext(WorkflowContext $context): void;
}

<?php

declare(strict_types=1);

namespace NeuronAI\Workflow\Exporter;

use NeuronAI\Workflow\Workflow;

interface ExporterInterface
{
    public function export(Workflow $graph): string;
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Validation;

interface ValidationRuleInterface
{
    public function validate(string $name, mixed $value, array &$violations): void;
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

class SchemaGenerated
{
    public function __construct(public string $class, public array $schema)
    {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

use NeuronAI\Workflow\WorkflowState;

class WorkflowEnd
{
    public function __construct(public WorkflowState $state)
    {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\XAI;

use NeuronAI\Providers\OpenAI\OpenAI;

class Grok extends OpenAI
{
    protected string $baseUri = 'https://api.x.ai/v1/';
}

<?php

declare(strict_types=1);

namespace NeuronAI;

trait StaticConstructor
{
    /**
     * Static constructor.
     */
    public static function make(...$arguments): static
    {
        /** @phpstan-ignore new.static */
        return new static(...$arguments);
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

class SchemaGeneration
{
    public function __construct(public string $class)
    {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

class Deserialized
{
    public function __construct(public string $class)
    {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Observability\Events;

class Deserializing
{
    public function __construct(public string $class)
    {
    }
}

<?php

declare(strict_types=1);

namespace NeuronAI\Exceptions;

class InvalidMessageInstance extends NeuronException
{
}

<?php

declare(strict_types=1);

namespace NeuronAI\Chat\Enums;

enum AttachmentType: string
{
    case DOCUMENT = 'document';
    case IMAGE = 'image';
}

<?php

declare(strict_types=1);

use Rector\Config\RectorConfig;
use Rector\TypeDeclaration\Rector\ClassMethod\AddReturnTypeDeclarationRector;

return RectorConfig::configure()
    ->withPaths([
        __DIR__ . '/src',
        __DIR__ . '/tests',
    ])
    ->withPhpSets(php81: true)
    ->withPreparedSets(
        codeQuality: true,
        deadCode: true,
        typeDeclarations: true,
        earlyReturn: true,
        strictBooleans: true,
    )
    ->withRules([
        AddReturnTypeDeclarationRector::class
    ]);

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\DataLoader;

use NeuronAI\RAG\Document;

interface DataLoaderInterface
{
    /**
     * @return Document[]
     */
    public function getDocuments(): array;
}

<?php

declare(strict_types=1);

namespace NeuronAI\Providers\HuggingFace;

enum InferenceProvider: string
{
    case HF_INFERENCE = 'hf-inference/models';
    case CEREBRAS = 'cerebras';
    case COHERE = 'cohere';
    case FEATHERLESS_AI = 'featherless-ai';
    case FIREWORKS_AI = 'fireworks-ai';
    case GROQ = 'groq';
    case HYPERBOLIC = 'hyperbolic';
    case NEBIUS = 'nebius';
    case NSCALE = 'nscale';
    case SAMBANOVA = 'sambanova';
    case TOGETHER = 'together';
}

<?php

declare(strict_types=1);

namespace NeuronAI\RAG\PreProcessor;

enum QueryTransformationType: string
{
    case REWRITING = 'rewriting';
    case DECOMPOSITION = 'decomposition';
    case HYDE = 'hyde';
}

<?php

declare(strict_types=1);

namespace NeuronAI\Exceptions;

class NeuronException extends \Exception
{
}

<?php

declare(strict_types=1);

namespace NeuronAI\MCP;

use NeuronAI\Exceptions\NeuronException;

class McpException extends NeuronException
{
}

<?php

declare(strict_types=1);

namespace NeuronAI\Exceptions;

class ToolCallableNotSet extends ToolException
{
}

<?php

declare(strict_types=1);

namespace NeuronAI\Exceptions;

class ChatHistoryException extends NeuronException
{
}

<?php

declare(strict_types=1);

namespace NeuronAI\Exceptions;

class ToolException extends NeuronException
{
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput\Deserializer;

use NeuronAI\Exceptions\NeuronException;

class DeserializerException extends NeuronException
{
}

<?php

declare(strict_types=1);

namespace NeuronAI\StructuredOutput;

use NeuronAI\Exceptions\NeuronException;

class StructuredOutputException extends NeuronException
{
}

<?php

declare(strict_types=1);

namespace NeuronAI\Exceptions;

class AgentException extends NeuronException
{
}

<?php

declare(strict_types=1);

namespace NeuronAI\Exceptions;

class WorkflowException extends NeuronException
{
}

<?php

declare(strict_types=1);

namespace NeuronAI\Exceptions;

class ProviderException extends NeuronException
{
}

<?php

declare(strict_types=1);

namespace NeuronAI\Exceptions;

class MissingCallbackParameter extends NeuronException
{
}

<?php

declare(strict_types=1);

namespace NeuronAI\Exceptions;

class ArrayPropertyException extends NeuronException
{
}

<?php

declare(strict_types=1);

namespace NeuronAI\Exceptions;

class StateGraphError extends NeuronException
{
}

<?php

declare(strict_types=1);

namespace NeuronAI\Exceptions;

class VectorStoreException extends NeuronException
{
}

<?php

declare(strict_types=1);

namespace NeuronAI\Exceptions;

class DataReaderException extends NeuronException
{
}

public function count (array $messages) : int {}

This code segment functions to: Calculate the total token count for a collection of messages by measuring content length variations, processing structured data formats, and applying character-to-token conversion ratios with supplemental overhead per message.

```php
[TASK_BEGIN]
public function count(array $messages): int
{
    $total = 0;

    foreach ($messages as $message) {
        if ($message instanceof ToolCallResultMessage) {
            $content = $message->result;
        } elseif ($message instanceof ToolCallMessage) {
            $content = $message->toolCall->arguments;
        } else {
            continue;
        }

        $total += strlen((string) $content) / $this->charsPerToken;
    }

    $total += count($messages) * $this->extraTokensPerMessage;

    return (int) round($total);
}
[TASK_END]
```

public function count(array $messages): int
    {
        $tokenCount = 0.0;
        foreach ($messages as $message) {
            $messageChars = 0;
            $content = $message->getContent();
            if (\is_string($content)) {
                $messageChars += \strlen($content);
            } elseif ($content !== null) {
                $messageChars += \strlen(\json_encode($content));
            }
            if ($message instanceof ToolCallMessage && !\is_array($content)) {
                $tools = $message->getTools();
                if ($tools !== []) {
                    $toolsContent = \json_encode(\array_map(fn (ToolInterface $tool): array => $tool->jsonSerialize(), $tools));
                    $messageChars += \strlen($toolsContent);
                }
            }
            if ($message instanceof ToolCallResultMessage) {
                $tools = $message->getTools();
                foreach ($tools as $tool) {
                    $serialized = $tool->jsonSerialize();
                    if (isset($serialized['id'])) {
                        $messageChars += \strlen($serialized['id']);
                    }
                }
            }
            $messageChars += \strlen($message->getRole());
            $tokenCount += \ceil($messageChars / $this->charsPerToken);
            $tokenCount += $this->extraTokensPerMessage;
        }
        return (int) \ceil($tokenCount);
    }

public function count(array $messages): int
{
    $total = 0;
    foreach ($messages as $message) {
        if ($message instanceof ToolCallResultMessage) {
            $content = $message->result;
        } elseif ($message instanceof ToolCallMessage) {
            $content = $message->toolCall->arguments;
        } else {
            continue;
        }
        $total += strlen((string) $content) / $this->charsPerToken;
    }
    $total += count($messages) * $this->extraTokensPerMessage;
    return (int) round($total);
}