Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,5 @@
tests/chunk.php
.idea/
.env
example.php
example.md
/examples/
/blueprints/
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,39 @@ $conversation->message(
$response = $conversation->send();
```

### Tool Calling

Tool calling is integrated into the existing conversation flow:

1. Register tools on the `Agent`
2. Send user message(s)
3. `Conversation::send()` automatically runs the model -> tool -> model loop
4. Receive the final assistant answer

Tool argument schema is inferred from callback signature (parameter names and scalar types).

```php
use Utopia\Agents\Agent;
use Utopia\Agents\Conversation;
use Utopia\Agents\Message;
use Utopia\Agents\Roles\User;
use Utopia\Agents\Adapters\OpenAI;

$agent = new Agent(new OpenAI('your-api-key', OpenAI::MODEL_O4_MINI));

$agent->addTool(
'sum',
fn (int $a, int $b): int => $a + $b,
'Add two integers'
);

$conversation = new Conversation($agent);
$conversation->message(new User('user-1'), new Message('What is 2 + 3?'));

$final = $conversation->send();
echo $final->getContent();
```

### Streaming Responses (SSE)

The conversation layer supports incremental output streaming through `Conversation::listen(callable $listener)`.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
},
"require": {
"php": ">=8.3",
"utopia-php/fetch": "0.5.*"
"utopia-php/fetch": "^1.1.0"
},
"require-dev": {
"laravel/pint": "^1.18",
Expand Down
17 changes: 9 additions & 8 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

256 changes: 256 additions & 0 deletions src/Agents/Adapters/Appwrite.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
<?php

namespace Utopia\Agents\Adapters;

use Utopia\Agents\Adapter;
use Utopia\Agents\Message;
use Utopia\Fetch\Client;

class Appwrite extends Adapter
{
/**
* NomicEmbedTextV15 - default general purpose text embedding model
*/
public const MODEL_NOMIC_EMBED_TEXT = 'nomic-embed-text';

/**
* EmbeddingGemma300M - Gemma embedding model
*/
public const MODEL_EMBEDDING_GEMMA = 'embedding-gemma';

/**
* AllMiniLML6V2 - small, fast sentence embedding model
*/
public const MODEL_ALL_MINILM = 'all-minilm';

/**
* BGESmallENV15 - small English embedding model
*/
public const MODEL_BGE_SMALL = 'bge-small';

protected string $model;

private string $endpoint = 'http://appwrite-embedding:11434/embed';

public const MODELS = [
self::MODEL_NOMIC_EMBED_TEXT,
self::MODEL_EMBEDDING_GEMMA,
self::MODEL_ALL_MINILM,
self::MODEL_BGE_SMALL,
];

/**
* Embedding dimensions of specific embedding model
*/
protected const DIMENSIONS = [
self::MODEL_NOMIC_EMBED_TEXT => 768,
self::MODEL_EMBEDDING_GEMMA => 768,
self::MODEL_ALL_MINILM => 384,
self::MODEL_BGE_SMALL => 384,
];

/**
* Create a new Appwrite embedding adapter (no API key required for local call)
*/
public function __construct(
string $model = self::MODEL_NOMIC_EMBED_TEXT,
int $timeout = 90000
) {
if (! in_array($model, self::MODELS, true)) {
throw new \InvalidArgumentException("Invalid model: {$model}. Supported models: ".implode(', ', self::MODELS));
}

$this->model = $model;
$this->setTimeout($timeout);
}

/**
* Embedding generation (the embedding service only supports embeddings, not chat)
*
* @return array{
* embedding: array<int, float>,
* tokensProcessed: int|null,
* totalDuration: int|null
* }
*
* @throws \Exception
*/
public function embed(string $text): array
{
$result = $this->bulkEmbed([$text]);

return [
'embedding' => $result['embeddings'][0],
'tokensProcessed' => $result['tokensProcessed'],
'totalDuration' => $result['totalDuration'],
];
}

/**
* Bulk embedding generation — sends multiple texts in a single request.
*
* @param array<int, string> $texts
* @return array{
* embeddings: array<int, array<int, float>>,
* tokensProcessed: int|null,
* totalDuration: int|null
* }
*
* @throws \Exception
*/
public function bulkEmbed(array $texts): array
{
if (empty($texts)) {
throw new \InvalidArgumentException('bulkEmbed requires at least one text');
}

$client = new Client();
$client->setTimeout($this->timeout);
$client->addHeader('Content-Type', 'application/json');
$payload = [
'model' => $this->model,
'texts' => array_values($texts),
];
$response = $client->fetch(
$this->getEndpoint(),
Client::METHOD_POST,
$payload
);
$body = $response->getBody();
$json = is_string($body) ? json_decode($body, true) : null;

if (! is_array($json)) {
throw new \Exception('Invalid response format received from the API');
}

if (isset($json['error'])) {
throw new \Exception(is_string($json['error']) ? $json['error'] : 'Unknown error', $response->getStatusCode());
}

if (! isset($json['embeddings']) || ! is_array($json['embeddings']) || count($json['embeddings']) !== count($texts)) {
throw new \Exception('Embedding response missing or count mismatch', $response->getStatusCode());
}

/** @var array<int, array<int, float>> $embeddings */
$embeddings = [];
foreach ($json['embeddings'] as $i => $vec) {
if (! is_array($vec) || $vec === []) {
throw new \Exception("Embedding row {$i} missing or empty", $response->getStatusCode());
}
/** @var array<int, float> $vec */
$embeddings[] = $vec;
}

return [
'embeddings' => $embeddings,
'tokensProcessed' => isset($json['tokens']) && is_int($json['tokens']) ? $json['tokens'] : null,
'totalDuration' => isset($json['total_duration']) && is_int($json['total_duration']) ? $json['total_duration'] : null,
];
}

/**
* Get available models for embeddings
*
* @return array<string>
*/
public function getModels(): array
{
return self::MODELS;
}

/**
* Get currently selected embedding model
*/
public function getModel(): string
{
return $this->model;
}

/**
* get embedding dimenion of the current model
*/
public function getEmbeddingDimension(): int
{
return self::DIMENSIONS[$this->model];
}

/**
* Set model to use for embedding
*/
public function setModel(string $model): self
{
if (! in_array($model, self::MODELS, true)) {
throw new \InvalidArgumentException("Invalid model: {$model}. Supported models: ".implode(', ', self::MODELS));
}
$this->model = $model;

return $this;
}

/**
* Not applicable for embedding-only adapters.
*
* @param array<\Utopia\Agents\Message> $messages
*
* @throws \Exception
*/
public function send(array $messages, ?callable $listener = null): Message
{
throw new \Exception('Appwrite does not support chat or messages. Use embed() instead.');
}

/**
* Embeddings do not support schema.
*/
public function isSchemaSupported(): bool
{
return false;
}

/**
* Get the adapter name
*/
public function getName(): string
{
return 'appwrite-embedding';
}

/**
* Error formatter (minimal)
*
* @param mixed $json
*/
protected function formatErrorMessage($json): string
{
if (! is_array($json)) {
return '(unknown_error) Unknown error';
}

$errorValue = $json['error'] ?? ($json['message'] ?? 'Unknown error');

return is_string($errorValue) ? $errorValue : 'Unknown error';
}

/**
* Get the API endpoint
*/
public function getEndpoint(): string
{
return $this->endpoint;
}

/**
* Set the API endpoint
*/
public function setEndpoint(string $endpoint): self
{
$this->endpoint = $endpoint;

return $this;
}

public function getSupportForEmbeddings(): bool
{
return true;
}
}
Loading