diff --git a/src/agent/src/Toolbox/Tool/Airtable.php b/src/agent/src/Toolbox/Tool/Airtable.php new file mode 100644 index 000000000..19477e054 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Airtable.php @@ -0,0 +1,502 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('airtable_get_records', 'Tool that gets Airtable records')] +#[AsTool('airtable_create_record', 'Tool that creates Airtable records', method: 'createRecord')] +#[AsTool('airtable_update_record', 'Tool that updates Airtable records', method: 'updateRecord')] +#[AsTool('airtable_delete_record', 'Tool that deletes Airtable records', method: 'deleteRecord')] +#[AsTool('airtable_search_records', 'Tool that searches Airtable records', method: 'searchRecords')] +#[AsTool('airtable_get_table_schema', 'Tool that gets Airtable table schema', method: 'getTableSchema')] +final readonly class Airtable +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + #[\SensitiveParameter] private string $baseId, + private array $options = [], + ) { + } + + /** + * Get Airtable records. + * + * @param string $tableName Airtable table name + * @param int $maxRecords Maximum number of records to retrieve + * @param string $view View name to filter records + * @param string $sortField Field to sort by + * @param string $sortDirection Sort direction (asc, desc) + * @param string $offset Pagination offset + * + * @return array{ + * records: array, + * }>, + * offset: string|null, + * }|string + */ + public function __invoke( + string $tableName, + int $maxRecords = 100, + string $view = '', + string $sortField = '', + string $sortDirection = 'asc', + string $offset = '', + ): array|string { + try { + $params = [ + 'maxRecords' => min(max($maxRecords, 1), 100), + ]; + + if ($view) { + $params['view'] = $view; + } + + if ($sortField) { + $params['sort'] = [ + [ + 'field' => $sortField, + 'direction' => $sortDirection, + ], + ]; + } + + if ($offset) { + $params['offset'] = $offset; + } + + $response = $this->httpClient->request('GET', "https://api.airtable.com/v0/{$this->baseId}/{$tableName}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting records: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'records' => array_map(fn ($record) => [ + 'id' => $record['id'], + 'createdTime' => $record['createdTime'], + 'fields' => $record['fields'], + ], $data['records']), + 'offset' => $data['offset'] ?? null, + ]; + } catch (\Exception $e) { + return 'Error getting records: '.$e->getMessage(); + } + } + + /** + * Create an Airtable record. + * + * @param string $tableName Airtable table name + * @param array $fields Record fields + * @param bool $typecast Whether to perform automatic data conversion + * + * @return array{ + * id: string, + * createdTime: string, + * fields: array, + * }|string + */ + public function createRecord( + string $tableName, + array $fields, + bool $typecast = false, + ): array|string { + try { + $payload = [ + 'records' => [ + [ + 'fields' => $fields, + ], + ], + ]; + + if ($typecast) { + $payload['typecast'] = true; + } + + $response = $this->httpClient->request('POST', "https://api.airtable.com/v0/{$this->baseId}/{$tableName}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating record: '.($data['error']['message'] ?? 'Unknown error'); + } + + $record = $data['records'][0]; + + return [ + 'id' => $record['id'], + 'createdTime' => $record['createdTime'], + 'fields' => $record['fields'], + ]; + } catch (\Exception $e) { + return 'Error creating record: '.$e->getMessage(); + } + } + + /** + * Update an Airtable record. + * + * @param string $tableName Airtable table name + * @param string $recordId Record ID to update + * @param array $fields Fields to update + * @param bool $typecast Whether to perform automatic data conversion + * + * @return array{ + * id: string, + * createdTime: string, + * fields: array, + * }|string + */ + public function updateRecord( + string $tableName, + string $recordId, + array $fields, + bool $typecast = false, + ): array|string { + try { + $payload = [ + 'records' => [ + [ + 'id' => $recordId, + 'fields' => $fields, + ], + ], + ]; + + if ($typecast) { + $payload['typecast'] = true; + } + + $response = $this->httpClient->request('PATCH', "https://api.airtable.com/v0/{$this->baseId}/{$tableName}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error updating record: '.($data['error']['message'] ?? 'Unknown error'); + } + + $record = $data['records'][0]; + + return [ + 'id' => $record['id'], + 'createdTime' => $record['createdTime'], + 'fields' => $record['fields'], + ]; + } catch (\Exception $e) { + return 'Error updating record: '.$e->getMessage(); + } + } + + /** + * Delete an Airtable record. + * + * @param string $tableName Airtable table name + * @param string $recordId Record ID to delete + */ + public function deleteRecord( + string $tableName, + string $recordId, + ): string { + try { + $response = $this->httpClient->request('DELETE', "https://api.airtable.com/v0/{$this->baseId}/{$tableName}/{$recordId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error deleting record: '.($data['error']['message'] ?? 'Unknown error'); + } + + return "Record {$recordId} deleted successfully"; + } catch (\Exception $e) { + return 'Error deleting record: '.$e->getMessage(); + } + } + + /** + * Search Airtable records. + * + * @param string $tableName Airtable table name + * @param string $filterFormula Filter formula + * @param int $maxRecords Maximum number of records to retrieve + * @param string $sortField Field to sort by + * @param string $sortDirection Sort direction (asc, desc) + * + * @return array{ + * records: array, + * }>, + * offset: string|null, + * }|string + */ + public function searchRecords( + string $tableName, + string $filterFormula, + int $maxRecords = 100, + string $sortField = '', + string $sortDirection = 'asc', + ): array|string { + try { + $params = [ + 'filterByFormula' => $filterFormula, + 'maxRecords' => min(max($maxRecords, 1), 100), + ]; + + if ($sortField) { + $params['sort'] = [ + [ + 'field' => $sortField, + 'direction' => $sortDirection, + ], + ]; + } + + $response = $this->httpClient->request('GET', "https://api.airtable.com/v0/{$this->baseId}/{$tableName}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error searching records: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'records' => array_map(fn ($record) => [ + 'id' => $record['id'], + 'createdTime' => $record['createdTime'], + 'fields' => $record['fields'], + ], $data['records']), + 'offset' => $data['offset'] ?? null, + ]; + } catch (\Exception $e) { + return 'Error searching records: '.$e->getMessage(); + } + } + + /** + * Get Airtable table schema. + * + * @param string $tableName Airtable table name + * + * @return array{ + * id: string, + * name: string, + * primaryFieldId: string, + * fields: array, + * }>, + * views: array, + * }|string + */ + public function getTableSchema(string $tableName): array|string + { + try { + $response = $this->httpClient->request('GET', "https://api.airtable.com/v0/meta/bases/{$this->baseId}/tables", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting table schema: '.($data['error']['message'] ?? 'Unknown error'); + } + + // Find the table by name + $table = null; + foreach ($data['tables'] as $t) { + if ($t['name'] === $tableName) { + $table = $t; + break; + } + } + + if (!$table) { + return 'Table not found: '.$tableName; + } + + return [ + 'id' => $table['id'], + 'name' => $table['name'], + 'primaryFieldId' => $table['primaryFieldId'], + 'fields' => array_map(fn ($field) => [ + 'id' => $field['id'], + 'name' => $field['name'], + 'type' => $field['type'], + 'description' => $field['description'] ?? '', + 'options' => $field['options'] ?? [], + ], $table['fields']), + 'views' => array_map(fn ($view) => [ + 'id' => $view['id'], + 'name' => $view['name'], + 'type' => $view['type'], + ], $table['views']), + ]; + } catch (\Exception $e) { + return 'Error getting table schema: '.$e->getMessage(); + } + } + + /** + * Bulk create Airtable records. + * + * @param string $tableName Airtable table name + * @param array> $records Array of records to create + * @param bool $typecast Whether to perform automatic data conversion + * + * @return array, + * }>|string + */ + public function bulkCreateRecords( + string $tableName, + array $records, + bool $typecast = false, + ): array|string { + try { + $payload = [ + 'records' => array_map(fn ($fields) => [ + 'fields' => $fields, + ], $records), + ]; + + if ($typecast) { + $payload['typecast'] = true; + } + + $response = $this->httpClient->request('POST', "https://api.airtable.com/v0/{$this->baseId}/{$tableName}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating records: '.($data['error']['message'] ?? 'Unknown error'); + } + + return array_map(fn ($record) => [ + 'id' => $record['id'], + 'createdTime' => $record['createdTime'], + 'fields' => $record['fields'], + ], $data['records']); + } catch (\Exception $e) { + return 'Error creating records: '.$e->getMessage(); + } + } + + /** + * Bulk update Airtable records. + * + * @param string $tableName Airtable table name + * @param array}> $records Array of records to update + * @param bool $typecast Whether to perform automatic data conversion + * + * @return array, + * }>|string + */ + public function bulkUpdateRecords( + string $tableName, + array $records, + bool $typecast = false, + ): array|string { + try { + $payload = [ + 'records' => $records, + ]; + + if ($typecast) { + $payload['typecast'] = true; + } + + $response = $this->httpClient->request('PATCH', "https://api.airtable.com/v0/{$this->baseId}/{$tableName}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error updating records: '.($data['error']['message'] ?? 'Unknown error'); + } + + return array_map(fn ($record) => [ + 'id' => $record['id'], + 'createdTime' => $record['createdTime'], + 'fields' => $record['fields'], + ], $data['records']); + } catch (\Exception $e) { + return 'Error updating records: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/AmadeusTravel.php b/src/agent/src/Toolbox/Tool/AmadeusTravel.php new file mode 100644 index 000000000..b1ee85bb3 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/AmadeusTravel.php @@ -0,0 +1,403 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('amadeus_flight_search', 'Tool that searches for flights using Amadeus API')] +#[AsTool('amadeus_hotel_search', 'Tool that searches for hotels using Amadeus API', method: 'searchHotels')] +#[AsTool('amadeus_airport_search', 'Tool that searches for airports using Amadeus API', method: 'searchAirports')] +#[AsTool('amadeus_city_search', 'Tool that searches for cities using Amadeus API', method: 'searchCities')] +final readonly class AmadeusTravel +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + #[\SensitiveParameter] private string $apiSecret, + private string $environment = 'test', // 'test' or 'production' + private array $options = [], + ) { + } + + /** + * Search for flights using Amadeus API. + * + * @param string $origin Origin airport IATA code (e.g., 'LAX') + * @param string $destination Destination airport IATA code (e.g., 'NYC') + * @param string $departureDate Departure date in YYYY-MM-DD format + * @param int $adults Number of adult passengers + * @param int $children Number of child passengers + * @param int $infants Number of infant passengers + * @param string $travelClass Travel class: ECONOMY, PREMIUM_ECONOMY, BUSINESS, FIRST + * @param int $maxResults Maximum number of results to return + * + * @return array, + * duration: string, + * stops: int, + * }> + */ + public function __invoke( + string $origin, + string $destination, + string $departureDate, + int $adults = 1, + int $children = 0, + int $infants = 0, + string $travelClass = 'ECONOMY', + int $maxResults = 10, + ): array { + try { + // Get access token first + $accessToken = $this->getAccessToken(); + if (!$accessToken) { + return [ + [ + 'price' => ['total' => '0', 'currency' => 'USD'], + 'segments' => [], + 'duration' => '0', + 'stops' => 0, + ], + ]; + } + + $baseUrl = 'production' === $this->environment + ? 'https://api.amadeus.com' + : 'https://test.api.amadeus.com'; + + $params = [ + 'originLocationCode' => $origin, + 'destinationLocationCode' => $destination, + 'departureDate' => $departureDate, + 'adults' => $adults, + 'travelClass' => $travelClass, + 'max' => $maxResults, + ]; + + if ($children > 0) { + $params['children'] = $children; + } + if ($infants > 0) { + $params['infants'] = $infants; + } + + $response = $this->httpClient->request('GET', $baseUrl.'/v2/shopping/flight-offers', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + 'Accept' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return []; + } + + $results = []; + foreach ($data['data'] as $offer) { + $flightData = [ + 'price' => [ + 'total' => $offer['price']['total'], + 'currency' => $offer['price']['currency'], + ], + 'segments' => [], + 'duration' => $offer['itineraries'][0]['duration'] ?? '', + 'stops' => \count($offer['itineraries'][0]['segments']) - 1, + ]; + + foreach ($offer['itineraries'][0]['segments'] as $segment) { + $flightData['segments'][] = [ + 'departure' => [ + 'at' => $segment['departure']['at'], + 'iataCode' => $segment['departure']['iataCode'], + ], + 'arrival' => [ + 'at' => $segment['arrival']['at'], + 'iataCode' => $segment['arrival']['iataCode'], + ], + 'flightNumber' => $segment['carrierCode'].$segment['number'], + 'carrier' => $segment['carrierCode'], + 'aircraft' => [ + 'code' => $segment['aircraft']['code'] ?? '', + 'name' => $segment['aircraft']['name'] ?? '', + ], + 'duration' => $segment['duration'], + ]; + } + + $results[] = $flightData; + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'price' => ['total' => '0', 'currency' => 'USD'], + 'segments' => [], + 'duration' => '0', + 'stops' => 0, + ], + ]; + } + } + + /** + * Search for hotels using Amadeus API. + * + * @param string $cityCode City IATA code + * @param string $checkInDate Check-in date in YYYY-MM-DD format + * @param string $checkOutDate Check-out date in YYYY-MM-DD format + * @param int $adults Number of adults + * @param int $rooms Number of rooms + * + * @return array, + * description: string, + * }> + */ + public function searchHotels( + string $cityCode, + string $checkInDate, + string $checkOutDate, + int $adults = 2, + int $rooms = 1, + ): array { + try { + $accessToken = $this->getAccessToken(); + if (!$accessToken) { + return []; + } + + $baseUrl = 'production' === $this->environment + ? 'https://api.amadeus.com' + : 'https://test.api.amadeus.com'; + + $response = $this->httpClient->request('GET', $baseUrl.'/v3/shopping/hotel-offers', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + 'Accept' => 'application/json', + ], + 'query' => array_merge($this->options, [ + 'cityCode' => $cityCode, + 'checkInDate' => $checkInDate, + 'checkOutDate' => $checkOutDate, + 'adults' => $adults, + 'roomQuantity' => $rooms, + ]), + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return []; + } + + $results = []; + foreach ($data['data'] as $hotel) { + $results[] = [ + 'hotel_id' => $hotel['hotel']['hotelId'], + 'name' => $hotel['hotel']['name'], + 'rating' => $hotel['hotel']['rating'] ?? 0, + 'price' => [ + 'total' => $hotel['offers'][0]['price']['total'] ?? '0', + 'currency' => $hotel['offers'][0]['price']['currency'] ?? 'USD', + ], + 'address' => $hotel['hotel']['address']['lines'][0] ?? '', + 'coordinates' => [ + 'latitude' => $hotel['hotel']['latitude'] ?? 0.0, + 'longitude' => $hotel['hotel']['longitude'] ?? 0.0, + ], + 'amenities' => $hotel['hotel']['amenities'] ?? [], + 'description' => $hotel['hotel']['description']['text'] ?? '', + ]; + } + + return $results; + } catch (\Exception $e) { + return []; + } + } + + /** + * Search for airports using Amadeus API. + * + * @param string $keyword Airport name or city name + * + * @return array + */ + public function searchAirports(string $keyword): array + { + try { + $accessToken = $this->getAccessToken(); + if (!$accessToken) { + return []; + } + + $baseUrl = 'production' === $this->environment + ? 'https://api.amadeus.com' + : 'https://test.api.amadeus.com'; + + $response = $this->httpClient->request('GET', $baseUrl.'/v1/reference-data/locations', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + 'Accept' => 'application/json', + ], + 'query' => array_merge($this->options, [ + 'subType' => 'AIRPORT', + 'keyword' => $keyword, + ]), + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return []; + } + + $results = []; + foreach ($data['data'] as $airport) { + $results[] = [ + 'iata_code' => $airport['iataCode'], + 'name' => $airport['name'], + 'city' => $airport['address']['cityName'] ?? '', + 'country' => $airport['address']['countryName'] ?? '', + 'coordinates' => [ + 'latitude' => $airport['geoCode']['latitude'] ?? 0.0, + 'longitude' => $airport['geoCode']['longitude'] ?? 0.0, + ], + ]; + } + + return $results; + } catch (\Exception $e) { + return []; + } + } + + /** + * Search for cities using Amadeus API. + * + * @param string $keyword City name + * + * @return array + */ + public function searchCities(string $keyword): array + { + try { + $accessToken = $this->getAccessToken(); + if (!$accessToken) { + return []; + } + + $baseUrl = 'production' === $this->environment + ? 'https://api.amadeus.com' + : 'https://test.api.amadeus.com'; + + $response = $this->httpClient->request('GET', $baseUrl.'/v1/reference-data/locations', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + 'Accept' => 'application/json', + ], + 'query' => array_merge($this->options, [ + 'subType' => 'CITY', + 'keyword' => $keyword, + ]), + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return []; + } + + $results = []; + foreach ($data['data'] as $city) { + $results[] = [ + 'iata_code' => $city['iataCode'], + 'name' => $city['name'], + 'country' => $city['address']['countryName'] ?? '', + 'coordinates' => [ + 'latitude' => $city['geoCode']['latitude'] ?? 0.0, + 'longitude' => $city['geoCode']['longitude'] ?? 0.0, + ], + ]; + } + + return $results; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get access token from Amadeus API. + */ + private function getAccessToken(): ?string + { + try { + $baseUrl = 'production' === $this->environment + ? 'https://api.amadeus.com' + : 'https://test.api.amadeus.com'; + + $response = $this->httpClient->request('POST', $baseUrl.'/v1/security/oauth2/token', [ + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => http_build_query([ + 'grant_type' => 'client_credentials', + 'client_id' => $this->apiKey, + 'client_secret' => $this->apiSecret, + ]), + ]); + + $data = $response->toArray(); + + return $data['access_token'] ?? null; + } catch (\Exception $e) { + return null; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Ansible.php b/src/agent/src/Toolbox/Tool/Ansible.php new file mode 100644 index 000000000..dd5b00851 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Ansible.php @@ -0,0 +1,428 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('ansible_playbook_run', 'Tool that runs Ansible playbooks')] +#[AsTool('ansible_inventory_list', 'Tool that lists Ansible inventory', method: 'listInventory')] +#[AsTool('ansible_ad_hoc', 'Tool that runs Ansible ad-hoc commands', method: 'adHoc')] +#[AsTool('ansible_galaxy_install', 'Tool that installs Ansible Galaxy roles', method: 'galaxyInstall')] +#[AsTool('ansible_vault_encrypt', 'Tool that encrypts files with Ansible Vault', method: 'vaultEncrypt')] +#[AsTool('ansible_vault_decrypt', 'Tool that decrypts files with Ansible Vault', method: 'vaultDecrypt')] +final readonly class Ansible +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $inventoryFile = '', + private array $options = [], + ) { + } + + /** + * Run Ansible playbook. + * + * @param string $playbook Playbook file path + * @param string $inventory Inventory file path + * @param string $extraVars Extra variables JSON + * @param string $limit Host limit + * @param string $tags Tags to run + * @param string $skipTags Tags to skip + * @param string $check Check mode + * @param string $diff Show differences + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * }|string + */ + public function __invoke( + string $playbook, + string $inventory = '', + string $extraVars = '', + string $limit = '', + string $tags = '', + string $skipTags = '', + string $check = 'false', + string $diff = 'false', + ): array|string { + try { + $command = ['ansible-playbook', $playbook]; + + $inventoryFile = $inventory ?: $this->inventoryFile; + if ($inventoryFile) { + $command[] = "-i {$inventoryFile}"; + } + + if ($extraVars) { + $command[] = "-e {$extraVars}"; + } + + if ($limit) { + $command[] = "--limit {$limit}"; + } + + if ($tags) { + $command[] = "--tags {$tags}"; + } + + if ($skipTags) { + $command[] = "--skip-tags {$skipTags}"; + } + + if ('true' === $check) { + $command[] = '--check'; + } + + if ('true' === $diff) { + $command[] = '--diff'; + } + + $output = $this->executeCommand($command); + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * List Ansible inventory. + * + * @param string $inventory Inventory file path + * @param string $host Specific host to list + * @param string $group Specific group to list + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * hosts: array, + * variables: array, + * }>, + * }|string + */ + public function listInventory( + string $inventory = '', + string $host = '', + string $group = '', + ): array|string { + try { + $command = ['ansible-inventory']; + + $inventoryFile = $inventory ?: $this->inventoryFile; + if ($inventoryFile) { + $command[] = "-i {$inventoryFile}"; + } + + if ($host) { + $command[] = "--host {$host}"; + } elseif ($group) { + $command[] = '--graph'; + } else { + $command[] = '--list'; + } + + $output = $this->executeCommand($command); + $data = json_decode($output, true); + + if ($host) { + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + 'hosts' => [ + [ + 'name' => $host, + 'groups' => [], + 'variables' => $data ?: [], + ], + ], + ]; + } + + $hosts = []; + if (\is_array($data)) { + foreach ($data as $groupName => $groupData) { + if (\is_array($groupData) && isset($groupData['hosts'])) { + foreach ($groupData['hosts'] as $hostName) { + $hosts[] = [ + 'name' => $hostName, + 'groups' => [$groupName], + 'variables' => $groupData['vars'] ?? [], + ]; + } + } + } + } + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + 'hosts' => $hosts, + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + 'hosts' => [], + ]; + } + } + + /** + * Run Ansible ad-hoc command. + * + * @param string $hosts Target hosts + * @param string $module Ansible module + * @param string $args Module arguments + * @param string $inventory Inventory file path + * @param string $become Use privilege escalation + * @param string $becomeUser Become user + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * }|string + */ + public function adHoc( + string $hosts, + string $module, + string $args = '', + string $inventory = '', + string $become = 'false', + string $becomeUser = '', + ): array|string { + try { + $command = ['ansible', $hosts]; + + $inventoryFile = $inventory ?: $this->inventoryFile; + if ($inventoryFile) { + $command[] = "-i {$inventoryFile}"; + } + + $command[] = "-m {$module}"; + + if ($args) { + $command[] = "-a {$args}"; + } + + if ('true' === $become) { + $command[] = '--become'; + + if ($becomeUser) { + $command[] = "--become-user {$becomeUser}"; + } + } + + $output = $this->executeCommand($command); + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Install Ansible Galaxy roles. + * + * @param string $requirements Requirements file path + * @param string $rolesPath Roles path + * @param string $force Force installation + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * }|string + */ + public function galaxyInstall( + string $requirements = '', + string $rolesPath = '', + string $force = 'false', + ): array|string { + try { + $command = ['ansible-galaxy', 'install']; + + if ($requirements) { + $command[] = "-r {$requirements}"; + } + + if ($rolesPath) { + $command[] = "-p {$rolesPath}"; + } + + if ('true' === $force) { + $command[] = '--force'; + } + + $output = $this->executeCommand($command); + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Encrypt file with Ansible Vault. + * + * @param string $file File to encrypt + * @param string $vaultId Vault ID + * @param string $vaultPasswordFile Vault password file + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * }|string + */ + public function vaultEncrypt( + string $file, + string $vaultId = '', + string $vaultPasswordFile = '', + ): array|string { + try { + $command = ['ansible-vault', 'encrypt', $file]; + + if ($vaultId) { + $command[] = "--vault-id {$vaultId}"; + } + + if ($vaultPasswordFile) { + $command[] = "--vault-password-file {$vaultPasswordFile}"; + } + + $output = $this->executeCommand($command); + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Decrypt file with Ansible Vault. + * + * @param string $file File to decrypt + * @param string $vaultId Vault ID + * @param string $vaultPasswordFile Vault password file + * @param string $output Output file path + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * }|string + */ + public function vaultDecrypt( + string $file, + string $vaultId = '', + string $vaultPasswordFile = '', + string $output = '', + ): array|string { + try { + $command = ['ansible-vault', 'decrypt', $file]; + + if ($vaultId) { + $command[] = "--vault-id {$vaultId}"; + } + + if ($vaultPasswordFile) { + $command[] = "--vault-password-file {$vaultPasswordFile}"; + } + + if ($output) { + $command[] = "--output {$output}"; + } + + $output = $this->executeCommand($command); + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Execute Ansible command. + */ + private function executeCommand(array $command): string + { + $commandString = implode(' ', array_map('escapeshellarg', $command)); + + $output = []; + $returnCode = 0; + + exec("{$commandString} 2>&1", $output, $returnCode); + + if (0 !== $returnCode) { + throw new \RuntimeException('Ansible command failed: '.implode("\n", $output)); + } + + return implode("\n", $output); + } +} diff --git a/src/agent/src/Toolbox/Tool/ArXiv.php b/src/agent/src/Toolbox/Tool/ArXiv.php new file mode 100644 index 000000000..a9d3eb9bc --- /dev/null +++ b/src/agent/src/Toolbox/Tool/ArXiv.php @@ -0,0 +1,257 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('arxiv_search', 'Tool that searches the ArXiv API for scientific papers')] +final readonly class ArXiv +{ + /** + * @param array $options Additional search options + */ + public function __construct( + private HttpClientInterface $httpClient, + private array $options = [], + ) { + } + + /** + * @param string $query search query to look up on ArXiv + * @param int $maxResults The maximum number of results to return + * @param int $start The starting position for results (for pagination) + * + * @return array, + * summary: string, + * published: string, + * updated: string, + * categories: array, + * link: string, + * }> + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + int $maxResults = 10, + #[With(minimum: 0)] + int $start = 0, + ): array { + try { + // Check if query is an ArXiv identifier + if ($this->isArxivIdentifier($query)) { + return $this->searchById($query); + } + + return $this->searchByQuery($query, $maxResults, $start); + } catch (\Exception $e) { + return [ + [ + 'id' => 'error', + 'title' => 'Search Error', + 'authors' => [], + 'summary' => 'Unable to perform ArXiv search: '.$e->getMessage(), + 'published' => '', + 'updated' => '', + 'categories' => [], + 'link' => '', + ], + ]; + } + } + + /** + * Check if a query is an ArXiv identifier. + */ + private function isArxivIdentifier(string $query): bool + { + // ArXiv identifier patterns + $patterns = [ + '/^\d{4}\.\d{4,5}(v\d+)?$/', // YYMM.NNNN or YYMM.NNNNvN + '/^\d{7}\.\d+$/', // YYMMNNN.N + '/^[a-z-]+\/\d{7}$/', // category/YYMMNNN + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $query)) { + return true; + } + } + + return false; + } + + /** + * Search ArXiv by specific paper ID. + * + * @return array, + * summary: string, + * published: string, + * updated: string, + * categories: array, + * link: string, + * }> + */ + private function searchById(string $id): array + { + $response = $this->httpClient->request('GET', 'http://export.arxiv.org/api/query', [ + 'query' => [ + 'id_list' => $id, + 'max_results' => 1, + ], + ]); + + $xml = $response->getContent(); + + return $this->parseArxivXml($xml); + } + + /** + * Search ArXiv by query string. + * + * @return array, + * summary: string, + * published: string, + * updated: string, + * categories: array, + * link: string, + * }> + */ + private function searchByQuery(string $query, int $maxResults, int $start): array + { + $response = $this->httpClient->request('GET', 'http://export.arxiv.org/api/query', [ + 'query' => array_merge($this->options, [ + 'search_query' => $query, + 'start' => $start, + 'max_results' => $maxResults, + 'sortBy' => 'relevance', + 'sortOrder' => 'descending', + ]), + ]); + + $xml = $response->getContent(); + + return $this->parseArxivXml($xml); + } + + /** + * Parse ArXiv XML response. + * + * @return array, + * summary: string, + * published: string, + * updated: string, + * categories: array, + * link: string, + * }> + */ + private function parseArxivXml(string $xml): array + { + try { + $dom = new \DOMDocument(); + $dom->loadXML($xml); + + $entries = $dom->getElementsByTagName('entry'); + $results = []; + + foreach ($entries as $entry) { + $id = $this->getElementText($entry, 'id'); + $title = $this->getElementText($entry, 'title'); + $summary = $this->getElementText($entry, 'summary'); + $published = $this->getElementText($entry, 'published'); + $updated = $this->getElementText($entry, 'updated'); + + // Extract authors + $authors = []; + $authorNodes = $entry->getElementsByTagName('author'); + foreach ($authorNodes as $authorNode) { + $name = $this->getElementText($authorNode, 'name'); + if ($name) { + $authors[] = $name; + } + } + + // Extract categories + $categories = []; + $categoryNodes = $entry->getElementsByTagName('category'); + foreach ($categoryNodes as $categoryNode) { + $term = $categoryNode->getAttribute('term'); + if ($term) { + $categories[] = $term; + } + } + + // Extract link + $link = ''; + $linkNodes = $entry->getElementsByTagName('link'); + foreach ($linkNodes as $linkNode) { + if ('pdf' === $linkNode->getAttribute('title')) { + $link = $linkNode->getAttribute('href'); + break; + } + } + + $results[] = [ + 'id' => $id, + 'title' => $title, + 'authors' => $authors, + 'summary' => trim($summary), + 'published' => $published, + 'updated' => $updated, + 'categories' => $categories, + 'link' => $link, + ]; + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'id' => 'parse_error', + 'title' => 'Parse Error', + 'authors' => [], + 'summary' => 'Unable to parse ArXiv response: '.$e->getMessage(), + 'published' => '', + 'updated' => '', + 'categories' => [], + 'link' => '', + ], + ]; + } + } + + private function getElementText(\DOMElement $parent, string $tagName): string + { + $elements = $parent->getElementsByTagName($tagName); + if ($elements->length > 0) { + return $elements->item(0)->textContent ?? ''; + } + + return ''; + } +} diff --git a/src/agent/src/Toolbox/Tool/Asana.php b/src/agent/src/Toolbox/Tool/Asana.php new file mode 100644 index 000000000..f7503333f --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Asana.php @@ -0,0 +1,510 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('asana_get_tasks', 'Tool that gets Asana tasks')] +#[AsTool('asana_create_task', 'Tool that creates Asana tasks', method: 'createTask')] +#[AsTool('asana_update_task', 'Tool that updates Asana tasks', method: 'updateTask')] +#[AsTool('asana_get_projects', 'Tool that gets Asana projects', method: 'getProjects')] +#[AsTool('asana_get_workspaces', 'Tool that gets Asana workspaces', method: 'getWorkspaces')] +#[AsTool('asana_get_users', 'Tool that gets Asana users', method: 'getUsers')] +final readonly class Asana +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private string $apiVersion = '1.0', + private array $options = [], + ) { + } + + /** + * Get Asana tasks. + * + * @param string $projectId Project ID to filter tasks + * @param string $assignee Assignee ID + * @param string $workspace Workspace ID + * @param bool $completed Include completed tasks + * @param int $limit Number of tasks to retrieve + * @param string $offset Pagination offset + * + * @return array, + * modified_at: string, + * num_hearts: int, + * num_likes: int, + * projects: array, + * tags: array, + * workspace: array{gid: string, name: string}, + * permalink_url: string, + * html_notes: string, + * }> + */ + public function __invoke( + string $projectId = '', + string $assignee = '', + string $workspace = '', + bool $completed = false, + int $limit = 50, + string $offset = '', + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), + 'opt_fields' => 'gid,name,notes,completed,assignee,assignee_status,completed_at,completed_by,created_at,due_on,due_at,followers,modified_at,num_hearts,num_likes,projects,tags,workspace,permalink_url,html_notes', + ]; + + if ($projectId) { + $params['project'] = $projectId; + } + if ($assignee) { + $params['assignee'] = $assignee; + } + if ($workspace) { + $params['workspace'] = $workspace; + } + if ($completed) { + $params['completed_since'] = '2012-02-22T02:06:58.147Z'; + } + if ($offset) { + $params['offset'] = $offset; + } + + $response = $this->httpClient->request('GET', "https://app.asana.com/api/{$this->apiVersion}/tasks", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return []; + } + + return array_map(fn ($task) => [ + 'gid' => $task['gid'], + 'name' => $task['name'], + 'resource_type' => $task['resource_type'], + 'notes' => $task['notes'] ?? '', + 'completed' => $task['completed'], + 'assignee' => $task['assignee'] ? [ + 'gid' => $task['assignee']['gid'], + 'name' => $task['assignee']['name'], + 'email' => $task['assignee']['email'] ?? '', + ] : null, + 'assignee_status' => $task['assignee_status'] ?? '', + 'completed_at' => $task['completed_at'], + 'completed_by' => $task['completed_by'] ? [ + 'gid' => $task['completed_by']['gid'], + 'name' => $task['completed_by']['name'], + ] : null, + 'created_at' => $task['created_at'], + 'due_on' => $task['due_on'], + 'due_at' => $task['due_at'], + 'followers' => array_map(fn ($follower) => [ + 'gid' => $follower['gid'], + 'name' => $follower['name'], + ], $task['followers'] ?? []), + 'modified_at' => $task['modified_at'], + 'num_hearts' => $task['num_hearts'] ?? 0, + 'num_likes' => $task['num_likes'] ?? 0, + 'projects' => array_map(fn ($project) => [ + 'gid' => $project['gid'], + 'name' => $project['name'], + ], $task['projects'] ?? []), + 'tags' => array_map(fn ($tag) => [ + 'gid' => $tag['gid'], + 'name' => $tag['name'], + ], $task['tags'] ?? []), + 'workspace' => [ + 'gid' => $task['workspace']['gid'], + 'name' => $task['workspace']['name'], + ], + 'permalink_url' => $task['permalink_url'], + 'html_notes' => $task['html_notes'] ?? '', + ], $data['data'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Create an Asana task. + * + * @param string $name Task name + * @param string $notes Task notes + * @param string $workspace Workspace ID + * @param string $project Project ID + * @param string $assignee Assignee ID + * @param string $dueOn Due date (YYYY-MM-DD) + * @param array $tags Tag IDs + * @param bool $completed Whether task is completed + * + * @return array{ + * gid: string, + * name: string, + * resource_type: string, + * notes: string, + * completed: bool, + * assignee: array{gid: string, name: string, email: string}|null, + * created_at: string, + * due_on: string|null, + * projects: array, + * tags: array, + * workspace: array{gid: string, name: string}, + * permalink_url: string, + * }|string + */ + public function createTask( + string $name, + string $notes = '', + string $workspace = '', + string $project = '', + string $assignee = '', + string $dueOn = '', + array $tags = [], + bool $completed = false, + ): array|string { + try { + $payload = [ + 'data' => [ + 'name' => $name, + 'completed' => $completed, + ], + ]; + + if ($notes) { + $payload['data']['notes'] = $notes; + } + if ($workspace) { + $payload['data']['workspace'] = $workspace; + } + if ($project) { + $payload['data']['projects'] = [$project]; + } + if ($assignee) { + $payload['data']['assignee'] = $assignee; + } + if ($dueOn) { + $payload['data']['due_on'] = $dueOn; + } + if (!empty($tags)) { + $payload['data']['tags'] = $tags; + } + + $response = $this->httpClient->request('POST', "https://app.asana.com/api/{$this->apiVersion}/tasks", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error creating task: '.($data['errors'][0]['message'] ?? 'Unknown error'); + } + + $task = $data['data']; + + return [ + 'gid' => $task['gid'], + 'name' => $task['name'], + 'resource_type' => $task['resource_type'], + 'notes' => $task['notes'] ?? '', + 'completed' => $task['completed'], + 'assignee' => $task['assignee'] ? [ + 'gid' => $task['assignee']['gid'], + 'name' => $task['assignee']['name'], + 'email' => $task['assignee']['email'] ?? '', + ] : null, + 'created_at' => $task['created_at'], + 'due_on' => $task['due_on'], + 'projects' => array_map(fn ($project) => [ + 'gid' => $project['gid'], + 'name' => $project['name'], + ], $task['projects'] ?? []), + 'tags' => array_map(fn ($tag) => [ + 'gid' => $tag['gid'], + 'name' => $tag['name'], + ], $task['tags'] ?? []), + 'workspace' => [ + 'gid' => $task['workspace']['gid'], + 'name' => $task['workspace']['name'], + ], + 'permalink_url' => $task['permalink_url'], + ]; + } catch (\Exception $e) { + return 'Error creating task: '.$e->getMessage(); + } + } + + /** + * Update an Asana task. + * + * @param string $taskGid Task GID to update + * @param string $name New name (optional) + * @param string $notes New notes (optional) + * @param string $assignee New assignee (optional) + * @param string $dueOn New due date (optional) + * @param bool $completed New completion status (optional) + */ + public function updateTask( + string $taskGid, + string $name = '', + string $notes = '', + string $assignee = '', + string $dueOn = '', + bool $completed = false, + ): string { + try { + $payload = ['data' => []]; + + if ($name) { + $payload['data']['name'] = $name; + } + if ($notes) { + $payload['data']['notes'] = $notes; + } + if ($assignee) { + $payload['data']['assignee'] = $assignee; + } + if ($dueOn) { + $payload['data']['due_on'] = $dueOn; + } + $payload['data']['completed'] = $completed; + + $response = $this->httpClient->request('PUT', "https://app.asana.com/api/{$this->apiVersion}/tasks/{$taskGid}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error updating task: '.($data['errors'][0]['message'] ?? 'Unknown error'); + } + + return 'Task updated successfully'; + } catch (\Exception $e) { + return 'Error updating task: '.$e->getMessage(); + } + } + + /** + * Get Asana projects. + * + * @param string $workspace Workspace ID + * @param bool $archived Include archived projects + * @param int $limit Number of projects to retrieve + * + * @return array + */ + public function getProjects( + string $workspace = '', + bool $archived = false, + int $limit = 50, + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), + 'opt_fields' => 'gid,name,notes,color,archived,created_at,modified_at,owner,team,workspace,permalink_url,html_notes', + ]; + + if ($workspace) { + $params['workspace'] = $workspace; + } + if ($archived) { + $params['archived'] = $archived; + } + + $response = $this->httpClient->request('GET', "https://app.asana.com/api/{$this->apiVersion}/projects", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return []; + } + + return array_map(fn ($project) => [ + 'gid' => $project['gid'], + 'name' => $project['name'], + 'resource_type' => $project['resource_type'], + 'notes' => $project['notes'] ?? '', + 'color' => $project['color'] ?? '', + 'archived' => $project['archived'], + 'created_at' => $project['created_at'], + 'modified_at' => $project['modified_at'], + 'owner' => [ + 'gid' => $project['owner']['gid'], + 'name' => $project['owner']['name'], + 'email' => $project['owner']['email'] ?? '', + ], + 'team' => [ + 'gid' => $project['team']['gid'], + 'name' => $project['team']['name'], + ], + 'workspace' => [ + 'gid' => $project['workspace']['gid'], + 'name' => $project['workspace']['name'], + ], + 'permalink_url' => $project['permalink_url'], + 'html_notes' => $project['html_notes'] ?? '', + ], $data['data'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Asana workspaces. + * + * @return array + */ + public function getWorkspaces(): array + { + try { + $response = $this->httpClient->request('GET', "https://app.asana.com/api/{$this->apiVersion}/workspaces", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return []; + } + + return array_map(fn ($workspace) => [ + 'gid' => $workspace['gid'], + 'name' => $workspace['name'], + 'resource_type' => $workspace['resource_type'], + 'is_organization' => $workspace['is_organization'], + ], $data['data'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Asana users. + * + * @param string $workspace Workspace ID + * @param int $limit Number of users to retrieve + * + * @return array, + * }> + */ + public function getUsers( + string $workspace = '', + int $limit = 50, + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), + 'opt_fields' => 'gid,name,email,photo,workspaces', + ]; + + if ($workspace) { + $params['workspace'] = $workspace; + } + + $response = $this->httpClient->request('GET', "https://app.asana.com/api/{$this->apiVersion}/users", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return []; + } + + return array_map(fn ($user) => [ + 'gid' => $user['gid'], + 'name' => $user['name'], + 'resource_type' => $user['resource_type'], + 'email' => $user['email'] ?? '', + 'photo' => $user['photo'] ? [ + 'gid' => $user['photo']['gid'], + 'image_128x128' => $user['photo']['image_128x128'], + ] : null, + 'workspaces' => array_map(fn ($workspace) => [ + 'gid' => $workspace['gid'], + 'name' => $workspace['name'], + ], $user['workspaces'] ?? []), + ], $data['data'] ?? []); + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/AskNews.php b/src/agent/src/Toolbox/Tool/AskNews.php new file mode 100644 index 000000000..8960a3eec --- /dev/null +++ b/src/agent/src/Toolbox/Tool/AskNews.php @@ -0,0 +1,891 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('asknews_search', 'Tool that searches for news using AskNews API')] +#[AsTool('asknews_get_trending', 'Tool that gets trending news topics', method: 'getTrending')] +#[AsTool('asknews_get_headlines', 'Tool that gets news headlines', method: 'getHeadlines')] +#[AsTool('asknews_get_article', 'Tool that gets full article content', method: 'getArticle')] +#[AsTool('asknews_get_sources', 'Tool that gets news sources', method: 'getSources')] +#[AsTool('asknews_get_categories', 'Tool that gets news categories', method: 'getCategories')] +#[AsTool('asknews_get_sentiment', 'Tool that gets news sentiment analysis', method: 'getSentiment')] +#[AsTool('asknews_get_summary', 'Tool that gets news summary', method: 'getSummary')] +final readonly class AskNews +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.asknews.com/v1', + private array $options = [], + ) { + } + + /** + * Search for news using AskNews API. + * + * @param string $query Search query + * @param string $language Language code (en, es, fr, de, etc.) + * @param string $country Country code (us, gb, ca, etc.) + * @param string $category News category + * @param string $sortBy Sort by (publishedAt, relevance, popularity) + * @param string $timeframe Timeframe (hour, day, week, month, year) + * @param int $limit Number of results + * @param int $offset Offset for pagination + * + * @return array{ + * success: bool, + * articles: array, + * sentiment: array{ + * score: float, + * label: string, + * }, + * readTime: int, + * wordCount: int, + * shares: int, + * engagement: float, + * }>, + * totalResults: int, + * query: string, + * searchMetadata: array{ + * searchId: string, + * totalResults: int, + * searchTime: float, + * query: string, + * filters: array, + * }, + * error: string, + * } + */ + public function __invoke( + string $query, + string $language = 'en', + string $country = 'us', + string $category = '', + string $sortBy = 'publishedAt', + string $timeframe = 'week', + int $limit = 20, + int $offset = 0, + ): array { + try { + $requestData = [ + 'q' => $query, + 'lang' => $language, + 'country' => $country, + 'sortBy' => $sortBy, + 'timeframe' => $timeframe, + 'limit' => max(1, min($limit, 100)), + 'offset' => max(0, $offset), + ]; + + if ($category) { + $requestData['category'] = $category; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $requestData), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'articles' => array_map(fn ($article) => [ + 'id' => $article['id'] ?? '', + 'title' => $article['title'] ?? '', + 'description' => $article['description'] ?? '', + 'content' => $article['content'] ?? '', + 'url' => $article['url'] ?? '', + 'imageUrl' => $article['imageUrl'] ?? '', + 'publishedAt' => $article['publishedAt'] ?? '', + 'source' => [ + 'id' => $article['source']['id'] ?? '', + 'name' => $article['source']['name'] ?? '', + 'url' => $article['source']['url'] ?? '', + 'category' => $article['source']['category'] ?? '', + ], + 'author' => $article['author'] ?? '', + 'language' => $article['language'] ?? $language, + 'country' => $article['country'] ?? $country, + 'category' => $article['category'] ?? $category, + 'tags' => $article['tags'] ?? [], + 'sentiment' => [ + 'score' => $article['sentiment']['score'] ?? 0.0, + 'label' => $article['sentiment']['label'] ?? 'neutral', + ], + 'readTime' => $article['readTime'] ?? 0, + 'wordCount' => $article['wordCount'] ?? 0, + 'shares' => $article['shares'] ?? 0, + 'engagement' => $article['engagement'] ?? 0.0, + ], $data['articles'] ?? []), + 'totalResults' => $data['totalResults'] ?? 0, + 'query' => $query, + 'searchMetadata' => [ + 'searchId' => $data['searchId'] ?? '', + 'totalResults' => $data['totalResults'] ?? 0, + 'searchTime' => $data['searchTime'] ?? 0.0, + 'query' => $query, + 'filters' => [ + 'language' => $language, + 'country' => $country, + 'category' => $category, + 'sortBy' => $sortBy, + 'timeframe' => $timeframe, + ], + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'articles' => [], + 'totalResults' => 0, + 'query' => $query, + 'searchMetadata' => [ + 'searchId' => '', + 'totalResults' => 0, + 'searchTime' => 0.0, + 'query' => $query, + 'filters' => [ + 'language' => $language, + 'country' => $country, + 'category' => $category, + 'sortBy' => $sortBy, + 'timeframe' => $timeframe, + ], + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get trending news topics. + * + * @param string $language Language code + * @param string $country Country code + * @param string $category News category + * @param int $limit Number of trending topics + * @param string $timeframe Timeframe for trending calculation + * + * @return array{ + * success: bool, + * trending: array, + * relatedTopics: array, + * sentiment: array{ + * score: float, + * label: string, + * }, + * }>, + * totalTrending: int, + * timeframe: string, + * error: string, + * } + */ + public function getTrending( + string $language = 'en', + string $country = 'us', + string $category = '', + int $limit = 20, + string $timeframe = 'day', + ): array { + try { + $requestData = [ + 'lang' => $language, + 'country' => $country, + 'limit' => max(1, min($limit, 100)), + 'timeframe' => $timeframe, + ]; + + if ($category) { + $requestData['category'] = $category; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/trending", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $requestData), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'trending' => array_map(fn ($trend) => [ + 'topic' => $trend['topic'] ?? '', + 'query' => $trend['query'] ?? '', + 'articleCount' => $trend['articleCount'] ?? 0, + 'trendScore' => $trend['trendScore'] ?? 0.0, + 'growthRate' => $trend['growthRate'] ?? 0.0, + 'category' => $trend['category'] ?? $category, + 'topArticles' => array_map(fn ($article) => [ + 'id' => $article['id'] ?? '', + 'title' => $article['title'] ?? '', + 'url' => $article['url'] ?? '', + 'publishedAt' => $article['publishedAt'] ?? '', + 'source' => $article['source'] ?? '', + ], $trend['topArticles'] ?? []), + 'relatedTopics' => $trend['relatedTopics'] ?? [], + 'sentiment' => [ + 'score' => $trend['sentiment']['score'] ?? 0.0, + 'label' => $trend['sentiment']['label'] ?? 'neutral', + ], + ], $data['trending'] ?? []), + 'totalTrending' => $data['totalTrending'] ?? 0, + 'timeframe' => $timeframe, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'trending' => [], + 'totalTrending' => 0, + 'timeframe' => $timeframe, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get news headlines. + * + * @param string $category News category + * @param string $language Language code + * @param string $country Country code + * @param int $limit Number of headlines + * + * @return array{ + * success: bool, + * headlines: array, + * category: string, + * totalHeadlines: int, + * error: string, + * } + */ + public function getHeadlines( + string $category = 'general', + string $language = 'en', + string $country = 'us', + int $limit = 20, + ): array { + try { + $requestData = [ + 'category' => $category, + 'lang' => $language, + 'country' => $country, + 'limit' => max(1, min($limit, 100)), + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/headlines", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $requestData), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'headlines' => array_map(fn ($headline) => [ + 'id' => $headline['id'] ?? '', + 'title' => $headline['title'] ?? '', + 'description' => $headline['description'] ?? '', + 'url' => $headline['url'] ?? '', + 'imageUrl' => $headline['imageUrl'] ?? '', + 'publishedAt' => $headline['publishedAt'] ?? '', + 'source' => $headline['source'] ?? '', + 'category' => $headline['category'] ?? $category, + 'priority' => $headline['priority'] ?? 'normal', + 'breaking' => $headline['breaking'] ?? false, + ], $data['headlines'] ?? []), + 'category' => $category, + 'totalHeadlines' => $data['totalHeadlines'] ?? 0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'headlines' => [], + 'category' => $category, + 'totalHeadlines' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get full article content. + * + * @param string $articleId Article ID + * @param bool $includeImages Include images in content + * @param bool $includeVideos Include videos in content + * + * @return array{ + * success: bool, + * article: array{ + * id: string, + * title: string, + * description: string, + * content: string, + * url: string, + * imageUrl: string, + * publishedAt: string, + * updatedAt: string, + * source: array{ + * id: string, + * name: string, + * url: string, + * category: string, + * }, + * author: string, + * language: string, + * country: string, + * category: string, + * tags: array, + * sentiment: array{ + * score: float, + * label: string, + * }, + * readTime: int, + * wordCount: int, + * shares: int, + * engagement: float, + * images: array, + * videos: array, + * relatedArticles: array, + * }, + * error: string, + * } + */ + public function getArticle( + string $articleId, + bool $includeImages = true, + bool $includeVideos = true, + ): array { + try { + $requestData = [ + 'includeImages' => $includeImages, + 'includeVideos' => $includeVideos, + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/articles/{$articleId}", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $requestData), + ]); + + $data = $response->toArray(); + $article = $data['article'] ?? []; + + return [ + 'success' => true, + 'article' => [ + 'id' => $article['id'] ?? $articleId, + 'title' => $article['title'] ?? '', + 'description' => $article['description'] ?? '', + 'content' => $article['content'] ?? '', + 'url' => $article['url'] ?? '', + 'imageUrl' => $article['imageUrl'] ?? '', + 'publishedAt' => $article['publishedAt'] ?? '', + 'updatedAt' => $article['updatedAt'] ?? '', + 'source' => [ + 'id' => $article['source']['id'] ?? '', + 'name' => $article['source']['name'] ?? '', + 'url' => $article['source']['url'] ?? '', + 'category' => $article['source']['category'] ?? '', + ], + 'author' => $article['author'] ?? '', + 'language' => $article['language'] ?? '', + 'country' => $article['country'] ?? '', + 'category' => $article['category'] ?? '', + 'tags' => $article['tags'] ?? [], + 'sentiment' => [ + 'score' => $article['sentiment']['score'] ?? 0.0, + 'label' => $article['sentiment']['label'] ?? 'neutral', + ], + 'readTime' => $article['readTime'] ?? 0, + 'wordCount' => $article['wordCount'] ?? 0, + 'shares' => $article['shares'] ?? 0, + 'engagement' => $article['engagement'] ?? 0.0, + 'images' => array_map(fn ($image) => [ + 'url' => $image['url'] ?? '', + 'caption' => $image['caption'] ?? '', + 'alt' => $image['alt'] ?? '', + ], $article['images'] ?? []), + 'videos' => array_map(fn ($video) => [ + 'url' => $video['url'] ?? '', + 'title' => $video['title'] ?? '', + 'duration' => $video['duration'] ?? 0, + ], $article['videos'] ?? []), + 'relatedArticles' => array_map(fn ($related) => [ + 'id' => $related['id'] ?? '', + 'title' => $related['title'] ?? '', + 'url' => $related['url'] ?? '', + 'publishedAt' => $related['publishedAt'] ?? '', + ], $article['relatedArticles'] ?? []), + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'article' => [ + 'id' => $articleId, + 'title' => '', + 'description' => '', + 'content' => '', + 'url' => '', + 'imageUrl' => '', + 'publishedAt' => '', + 'updatedAt' => '', + 'source' => ['id' => '', 'name' => '', 'url' => '', 'category' => ''], + 'author' => '', + 'language' => '', + 'country' => '', + 'category' => '', + 'tags' => [], + 'sentiment' => ['score' => 0.0, 'label' => 'neutral'], + 'readTime' => 0, + 'wordCount' => 0, + 'shares' => 0, + 'engagement' => 0.0, + 'images' => [], + 'videos' => [], + 'relatedArticles' => [], + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get news sources. + * + * @param string $language Language code + * @param string $country Country code + * @param string $category News category + * @param int $limit Number of sources + * + * @return array{ + * success: bool, + * sources: array, + * totalSources: int, + * error: string, + * } + */ + public function getSources( + string $language = 'en', + string $country = 'us', + string $category = '', + int $limit = 50, + ): array { + try { + $requestData = [ + 'lang' => $language, + 'country' => $country, + 'limit' => max(1, min($limit, 100)), + ]; + + if ($category) { + $requestData['category'] = $category; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/sources", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $requestData), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'sources' => array_map(fn ($source) => [ + 'id' => $source['id'] ?? '', + 'name' => $source['name'] ?? '', + 'url' => $source['url'] ?? '', + 'category' => $source['category'] ?? $category, + 'language' => $source['language'] ?? $language, + 'country' => $source['country'] ?? $country, + 'description' => $source['description'] ?? '', + 'logoUrl' => $source['logoUrl'] ?? '', + 'credibility' => [ + 'score' => $source['credibility']['score'] ?? 0.0, + 'label' => $source['credibility']['label'] ?? 'unknown', + ], + 'bias' => [ + 'score' => $source['bias']['score'] ?? 0.0, + 'label' => $source['bias']['label'] ?? 'neutral', + ], + 'articleCount' => $source['articleCount'] ?? 0, + 'lastUpdated' => $source['lastUpdated'] ?? '', + ], $data['sources'] ?? []), + 'totalSources' => $data['totalSources'] ?? 0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'sources' => [], + 'totalSources' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get news categories. + * + * @param string $language Language code + * @param string $country Country code + * + * @return array{ + * success: bool, + * categories: array, + * articleCount: int, + * trending: bool, + * }>, + * totalCategories: int, + * error: string, + * } + */ + public function getCategories( + string $language = 'en', + string $country = 'us', + ): array { + try { + $requestData = [ + 'lang' => $language, + 'country' => $country, + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/categories", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $requestData), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'categories' => array_map(fn ($category) => [ + 'id' => $category['id'] ?? '', + 'name' => $category['name'] ?? '', + 'description' => $category['description'] ?? '', + 'parentCategory' => $category['parentCategory'] ?? '', + 'subcategories' => array_map(fn ($sub) => [ + 'id' => $sub['id'] ?? '', + 'name' => $sub['name'] ?? '', + 'description' => $sub['description'] ?? '', + ], $category['subcategories'] ?? []), + 'articleCount' => $category['articleCount'] ?? 0, + 'trending' => $category['trending'] ?? false, + ], $data['categories'] ?? []), + 'totalCategories' => $data['totalCategories'] ?? 0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'categories' => [], + 'totalCategories' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get news sentiment analysis. + * + * @param string $articleId Article ID + * @param bool $includeEmotions Include emotion analysis + * @param bool $includeEntities Include entity analysis + * + * @return array{ + * success: bool, + * sentiment: array{ + * overall: array{ + * score: float, + * label: string, + * confidence: float, + * }, + * emotions: array, + * entities: array, + * topics: array, + * summary: string, + * }, + * articleId: string, + * error: string, + * } + */ + public function getSentiment( + string $articleId, + bool $includeEmotions = true, + bool $includeEntities = true, + ): array { + try { + $requestData = [ + 'includeEmotions' => $includeEmotions, + 'includeEntities' => $includeEntities, + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/articles/{$articleId}/sentiment", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $requestData), + ]); + + $data = $response->toArray(); + $sentiment = $data['sentiment'] ?? []; + + return [ + 'success' => true, + 'sentiment' => [ + 'overall' => [ + 'score' => $sentiment['overall']['score'] ?? 0.0, + 'label' => $sentiment['overall']['label'] ?? 'neutral', + 'confidence' => $sentiment['overall']['confidence'] ?? 0.0, + ], + 'emotions' => $sentiment['emotions'] ?? [], + 'entities' => array_map(fn ($entity) => [ + 'text' => $entity['text'] ?? '', + 'type' => $entity['type'] ?? '', + 'sentiment' => [ + 'score' => $entity['sentiment']['score'] ?? 0.0, + 'label' => $entity['sentiment']['label'] ?? 'neutral', + ], + ], $sentiment['entities'] ?? []), + 'topics' => array_map(fn ($topic) => [ + 'name' => $topic['name'] ?? '', + 'sentiment' => [ + 'score' => $topic['sentiment']['score'] ?? 0.0, + 'label' => $topic['sentiment']['label'] ?? 'neutral', + ], + ], $sentiment['topics'] ?? []), + 'summary' => $sentiment['summary'] ?? '', + ], + 'articleId' => $articleId, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'sentiment' => [ + 'overall' => ['score' => 0.0, 'label' => 'neutral', 'confidence' => 0.0], + 'emotions' => [], + 'entities' => [], + 'topics' => [], + 'summary' => '', + ], + 'articleId' => $articleId, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get news summary. + * + * @param string $articleId Article ID + * @param int $maxLength Maximum summary length + * @param string $style Summary style (brief, detailed, bulleted) + * + * @return array{ + * success: bool, + * summary: array{ + * text: string, + * length: int, + * style: string, + * keyPoints: array, + * keywords: array, + * topics: array, + * confidence: float, + * }, + * articleId: string, + * error: string, + * } + */ + public function getSummary( + string $articleId, + int $maxLength = 200, + string $style = 'brief', + ): array { + try { + $requestData = [ + 'maxLength' => max(50, min($maxLength, 1000)), + 'style' => $style, + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/articles/{$articleId}/summary", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $requestData), + ]); + + $data = $response->toArray(); + $summary = $data['summary'] ?? []; + + return [ + 'success' => true, + 'summary' => [ + 'text' => $summary['text'] ?? '', + 'length' => $summary['length'] ?? 0, + 'style' => $summary['style'] ?? $style, + 'keyPoints' => $summary['keyPoints'] ?? [], + 'keywords' => $summary['keywords'] ?? [], + 'topics' => $summary['topics'] ?? [], + 'confidence' => $summary['confidence'] ?? 0.0, + ], + 'articleId' => $articleId, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'summary' => [ + 'text' => '', + 'length' => 0, + 'style' => $style, + 'keyPoints' => [], + 'keywords' => [], + 'topics' => [], + 'confidence' => 0.0, + ], + 'articleId' => $articleId, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Aws.php b/src/agent/src/Toolbox/Tool/Aws.php new file mode 100644 index 000000000..1adfbed46 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Aws.php @@ -0,0 +1,459 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('aws_s3_list_buckets', 'Tool that lists AWS S3 buckets')] +#[AsTool('aws_ec2_describe_instances', 'Tool that describes AWS EC2 instances', method: 'describeInstances')] +#[AsTool('aws_lambda_list_functions', 'Tool that lists AWS Lambda functions', method: 'listFunctions')] +#[AsTool('aws_rds_describe_instances', 'Tool that describes AWS RDS instances', method: 'describeRdsInstances')] +#[AsTool('aws_iam_list_users', 'Tool that lists AWS IAM users', method: 'listUsers')] +#[AsTool('aws_cloudwatch_get_metrics', 'Tool that gets AWS CloudWatch metrics', method: 'getMetrics')] +final readonly class Aws +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessKeyId = '', + #[\SensitiveParameter] private string $secretAccessKey = '', + private string $region = 'us-east-1', + private array $options = [], + ) { + } + + /** + * List AWS S3 buckets. + * + * @return array + */ + public function __invoke(): array + { + try { + $headers = [ + 'Content-Type' => 'application/x-amz-json-1.0', + 'X-Amz-Target' => 'AmazonS3.ListBuckets', + ]; + + if ($this->accessKeyId && $this->secretAccessKey) { + $headers['Authorization'] = 'AWS '.$this->accessKeyId.':'.$this->secretAccessKey; + } + + $response = $this->httpClient->request('GET', "https://s3.{$this->region}.amazonaws.com/", [ + 'headers' => $headers, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($bucket) => [ + 'name' => $bucket['Name'], + 'creationDate' => $bucket['CreationDate'], + ], $data['Buckets'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Describe AWS EC2 instances. + * + * @param string $instanceIds Comma-separated instance IDs + * @param string $filters JSON filters + * + * @return array, + * }> + */ + public function describeInstances( + string $instanceIds = '', + string $filters = '', + ): array { + try { + $params = []; + + if ($instanceIds) { + $params['InstanceIds'] = explode(',', $instanceIds); + } + if ($filters) { + $params['Filters'] = json_decode($filters, true); + } + + $headers = [ + 'Content-Type' => 'application/x-amz-json-1.1', + 'X-Amz-Target' => 'AmazonEC2.DescribeInstances', + ]; + + if ($this->accessKeyId && $this->secretAccessKey) { + $headers['Authorization'] = 'AWS '.$this->accessKeyId.':'.$this->secretAccessKey; + } + + $response = $this->httpClient->request('POST', "https://ec2.{$this->region}.amazonaws.com/", [ + 'headers' => $headers, + 'json' => $params, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + $instances = []; + foreach ($data['Reservations'] ?? [] as $reservation) { + foreach ($reservation['Instances'] ?? [] as $instance) { + $instances[] = [ + 'instanceId' => $instance['InstanceId'], + 'instanceType' => $instance['InstanceType'], + 'state' => $instance['State']['Name'], + 'publicIpAddress' => $instance['PublicIpAddress'] ?? '', + 'privateIpAddress' => $instance['PrivateIpAddress'] ?? '', + 'launchTime' => $instance['LaunchTime'], + 'tags' => array_column($instance['Tags'] ?? [], 'Value', 'Key'), + ]; + } + } + + return $instances; + } catch (\Exception $e) { + return []; + } + } + + /** + * List AWS Lambda functions. + * + * @param string $functionVersion Function version filter + * @param string $masterRegion Master region filter + * + * @return array|null, + * environment: array|null, + * }> + */ + public function listFunctions( + string $functionVersion = '', + string $masterRegion = '', + ): array { + try { + $params = []; + + if ($functionVersion) { + $params['FunctionVersion'] = $functionVersion; + } + if ($masterRegion) { + $params['MasterRegion'] = $masterRegion; + } + + $headers = [ + 'Content-Type' => 'application/x-amz-json-1.1', + 'X-Amz-Target' => 'AWSLambda.ListFunctions', + ]; + + if ($this->accessKeyId && $this->secretAccessKey) { + $headers['Authorization'] = 'AWS '.$this->accessKeyId.':'.$this->secretAccessKey; + } + + $response = $this->httpClient->request('POST', "https://lambda.{$this->region}.amazonaws.com/", [ + 'headers' => $headers, + 'json' => $params, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($function) => [ + 'functionName' => $function['FunctionName'], + 'functionArn' => $function['FunctionArn'], + 'runtime' => $function['Runtime'], + 'role' => $function['Role'], + 'handler' => $function['Handler'], + 'codeSize' => $function['CodeSize'], + 'description' => $function['Description'] ?? '', + 'timeout' => $function['Timeout'], + 'memorySize' => $function['MemorySize'], + 'lastModified' => $function['LastModified'], + 'codeSha256' => $function['CodeSha256'], + 'version' => $function['Version'], + 'vpcConfig' => $function['VpcConfig'] ?? null, + 'environment' => $function['Environment'] ?? null, + ], $data['Functions'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Describe AWS RDS instances. + * + * @param string $dbInstanceIdentifier DB instance identifier + * @param string $filters JSON filters + * + * @return array, + * }> + */ + public function describeRdsInstances( + string $dbInstanceIdentifier = '', + string $filters = '', + ): array { + try { + $params = []; + + if ($dbInstanceIdentifier) { + $params['DBInstanceIdentifier'] = $dbInstanceIdentifier; + } + if ($filters) { + $params['Filters'] = json_decode($filters, true); + } + + $headers = [ + 'Content-Type' => 'application/x-amz-json-1.1', + 'X-Amz-Target' => 'AmazonRDS.DescribeDBInstances', + ]; + + if ($this->accessKeyId && $this->secretAccessKey) { + $headers['Authorization'] = 'AWS '.$this->accessKeyId.':'.$this->secretAccessKey; + } + + $response = $this->httpClient->request('POST', "https://rds.{$this->region}.amazonaws.com/", [ + 'headers' => $headers, + 'json' => $params, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($instance) => [ + 'dbInstanceIdentifier' => $instance['DBInstanceIdentifier'], + 'dbInstanceClass' => $instance['DBInstanceClass'], + 'engine' => $instance['Engine'], + 'engineVersion' => $instance['EngineVersion'], + 'dbInstanceStatus' => $instance['DBInstanceStatus'], + 'masterUsername' => $instance['MasterUsername'], + 'dbName' => $instance['DBName'] ?? '', + 'allocatedStorage' => $instance['AllocatedStorage'], + 'storageType' => $instance['StorageType'], + 'availabilityZone' => $instance['AvailabilityZone'], + 'multiAz' => $instance['MultiAZ'], + 'publiclyAccessible' => $instance['PubliclyAccessible'], + 'endpoint' => $instance['Endpoint'] ? [ + 'address' => $instance['Endpoint']['Address'], + 'port' => $instance['Endpoint']['Port'], + ] : null, + 'tags' => array_column($instance['TagList'] ?? [], 'Value', 'Key'), + ], $data['DBInstances'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * List AWS IAM users. + * + * @param string $pathPrefix Path prefix filter + * @param string $marker Pagination marker + * @param int $maxItems Maximum items to return + * + * @return array, + * }> + */ + public function listUsers( + string $pathPrefix = '', + string $marker = '', + int $maxItems = 100, + ): array { + try { + $params = [ + 'MaxItems' => min(max($maxItems, 1), 1000), + ]; + + if ($pathPrefix) { + $params['PathPrefix'] = $pathPrefix; + } + if ($marker) { + $params['Marker'] = $marker; + } + + $headers = [ + 'Content-Type' => 'application/x-amz-json-1.1', + 'X-Amz-Target' => 'AmazonIAM.ListUsers', + ]; + + if ($this->accessKeyId && $this->secretAccessKey) { + $headers['Authorization'] = 'AWS '.$this->accessKeyId.':'.$this->secretAccessKey; + } + + $response = $this->httpClient->request('POST', 'https://iam.amazonaws.com/', [ + 'headers' => $headers, + 'json' => $params, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($user) => [ + 'userName' => $user['UserName'], + 'userId' => $user['UserId'], + 'arn' => $user['Arn'], + 'path' => $user['Path'], + 'createDate' => $user['CreateDate'], + 'passwordLastUsed' => $user['PasswordLastUsed'] ?? '', + 'tags' => array_column($user['Tags'] ?? [], 'Value', 'Key'), + ], $data['Users'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get AWS CloudWatch metrics. + * + * @param string $namespace Metric namespace + * @param string $metricName Metric name + * @param string $dimensions JSON dimensions + * @param string $startTime Start time (ISO 8601) + * @param string $endTime End time (ISO 8601) + * @param int $period Period in seconds + * @param string $statistics Comma-separated statistics (Average, Sum, Maximum, Minimum, SampleCount) + * + * @return array{ + * label: string, + * datapoints: array, + * }|string + */ + public function getMetrics( + string $namespace, + string $metricName, + string $dimensions = '', + string $startTime = '', + string $endTime = '', + int $period = 300, + string $statistics = 'Average', + ): array|string { + try { + $params = [ + 'Namespace' => $namespace, + 'MetricName' => $metricName, + 'Period' => $period, + 'Statistics' => explode(',', $statistics), + ]; + + if ($dimensions) { + $params['Dimensions'] = json_decode($dimensions, true); + } + if ($startTime) { + $params['StartTime'] = $startTime; + } + if ($endTime) { + $params['EndTime'] = $endTime; + } + + $headers = [ + 'Content-Type' => 'application/x-amz-json-1.0', + 'X-Amz-Target' => 'CloudWatch.GetMetricStatistics', + ]; + + if ($this->accessKeyId && $this->secretAccessKey) { + $headers['Authorization'] = 'AWS '.$this->accessKeyId.':'.$this->secretAccessKey; + } + + $response = $this->httpClient->request('POST', "https://monitoring.{$this->region}.amazonaws.com/", [ + 'headers' => $headers, + 'json' => $params, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting CloudWatch metrics: '.($data['error']['Message'] ?? 'Unknown error'); + } + + return [ + 'label' => $data['Label'], + 'datapoints' => array_map(fn ($datapoint) => [ + 'timestamp' => $datapoint['Timestamp'], + 'value' => $datapoint['Average'] ?? $datapoint['Sum'] ?? $datapoint['Maximum'] ?? $datapoint['Minimum'] ?? $datapoint['SampleCount'] ?? 0.0, + 'unit' => $datapoint['Unit'], + ], $data['Datapoints'] ?? []), + ]; + } catch (\Exception $e) { + return 'Error getting CloudWatch metrics: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Azure.php b/src/agent/src/Toolbox/Tool/Azure.php new file mode 100644 index 000000000..e57912125 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Azure.php @@ -0,0 +1,636 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('azure_vm_list', 'Tool that lists Azure virtual machines')] +#[AsTool('azure_storage_list_accounts', 'Tool that lists Azure storage accounts', method: 'listStorageAccounts')] +#[AsTool('azure_sql_list_servers', 'Tool that lists Azure SQL servers', method: 'listSqlServers')] +#[AsTool('azure_resource_groups_list', 'Tool that lists Azure resource groups', method: 'listResourceGroups')] +#[AsTool('azure_webapps_list', 'Tool that lists Azure web apps', method: 'listWebApps')] +#[AsTool('azure_functions_list', 'Tool that lists Azure Functions', method: 'listFunctions')] +final readonly class Azure +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken = '', + private string $subscriptionId = '', + private string $tenantId = '', + private string $clientId = '', + private string $apiVersion = '2021-03-01', + private array $options = [], + ) { + } + + /** + * List Azure virtual machines. + * + * @param string $resourceGroupName Resource group name (optional) + * @param string $filter OData filter expression + * + * @return array, + * properties: array{ + * vmId: string, + * hardwareProfile: array{ + * vmSize: string, + * }, + * storageProfile: array{ + * imageReference: array{ + * publisher: string, + * offer: string, + * sku: string, + * version: string, + * }, + * osDisk: array{ + * osType: string, + * name: string, + * createOption: string, + * diskSizeGB: int, + * managedDisk: array{ + * id: string, + * storageAccountType: string, + * }, + * }, + * }, + * osProfile: array{ + * computerName: string, + * adminUsername: string, + * }, + * networkProfile: array{ + * networkInterfaces: array, + * }, + * provisioningState: string, + * }, + * }> + */ + public function __invoke( + string $resourceGroupName = '', + string $filter = '', + ): array { + try { + $params = [ + 'api-version' => $this->apiVersion, + ]; + + if ($filter) { + $params['$filter'] = $filter; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->accessToken) { + $headers['Authorization'] = 'Bearer '.$this->accessToken; + } + + $url = $resourceGroupName + ? "https://management.azure.com/subscriptions/{$this->subscriptionId}/resourceGroups/{$resourceGroupName}/providers/Microsoft.Compute/virtualMachines" + : "https://management.azure.com/subscriptions/{$this->subscriptionId}/providers/Microsoft.Compute/virtualMachines"; + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($vm) => [ + 'id' => $vm['id'], + 'name' => $vm['name'], + 'type' => $vm['type'], + 'location' => $vm['location'], + 'tags' => $vm['tags'] ?? [], + 'properties' => [ + 'vmId' => $vm['properties']['vmId'], + 'hardwareProfile' => [ + 'vmSize' => $vm['properties']['hardwareProfile']['vmSize'], + ], + 'storageProfile' => [ + 'imageReference' => [ + 'publisher' => $vm['properties']['storageProfile']['imageReference']['publisher'], + 'offer' => $vm['properties']['storageProfile']['imageReference']['offer'], + 'sku' => $vm['properties']['storageProfile']['imageReference']['sku'], + 'version' => $vm['properties']['storageProfile']['imageReference']['version'], + ], + 'osDisk' => [ + 'osType' => $vm['properties']['storageProfile']['osDisk']['osType'], + 'name' => $vm['properties']['storageProfile']['osDisk']['name'], + 'createOption' => $vm['properties']['storageProfile']['osDisk']['createOption'], + 'diskSizeGB' => $vm['properties']['storageProfile']['osDisk']['diskSizeGB'], + 'managedDisk' => [ + 'id' => $vm['properties']['storageProfile']['osDisk']['managedDisk']['id'], + 'storageAccountType' => $vm['properties']['storageProfile']['osDisk']['managedDisk']['storageAccountType'], + ], + ], + ], + 'osProfile' => [ + 'computerName' => $vm['properties']['osProfile']['computerName'], + 'adminUsername' => $vm['properties']['osProfile']['adminUsername'], + ], + 'networkProfile' => [ + 'networkInterfaces' => array_map(fn ($nic) => [ + 'id' => $nic['id'], + ], $vm['properties']['networkProfile']['networkInterfaces']), + ], + 'provisioningState' => $vm['properties']['provisioningState'], + ], + ], $data['value'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * List Azure storage accounts. + * + * @param string $resourceGroupName Resource group name (optional) + * + * @return array, + * properties: array{ + * provisioningState: string, + * primaryEndpoints: array{ + * blob: string, + * queue: string, + * table: string, + * file: string, + * }, + * primaryLocation: string, + * statusOfPrimary: string, + * creationTime: string, + * accountType: string, + * }, + * }> + */ + public function listStorageAccounts(string $resourceGroupName = ''): array + { + try { + $params = [ + 'api-version' => '2021-06-01', + ]; + + $headers = ['Content-Type' => 'application/json']; + if ($this->accessToken) { + $headers['Authorization'] = 'Bearer '.$this->accessToken; + } + + $url = $resourceGroupName + ? "https://management.azure.com/subscriptions/{$this->subscriptionId}/resourceGroups/{$resourceGroupName}/providers/Microsoft.Storage/storageAccounts" + : "https://management.azure.com/subscriptions/{$this->subscriptionId}/providers/Microsoft.Storage/storageAccounts"; + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($account) => [ + 'id' => $account['id'], + 'name' => $account['name'], + 'type' => $account['type'], + 'location' => $account['location'], + 'tags' => $account['tags'] ?? [], + 'properties' => [ + 'provisioningState' => $account['properties']['provisioningState'], + 'primaryEndpoints' => [ + 'blob' => $account['properties']['primaryEndpoints']['blob'] ?? '', + 'queue' => $account['properties']['primaryEndpoints']['queue'] ?? '', + 'table' => $account['properties']['primaryEndpoints']['table'] ?? '', + 'file' => $account['properties']['primaryEndpoints']['file'] ?? '', + ], + 'primaryLocation' => $account['properties']['primaryLocation'], + 'statusOfPrimary' => $account['properties']['statusOfPrimary'], + 'creationTime' => $account['properties']['creationTime'], + 'accountType' => $account['properties']['accountType'] ?? 'Standard_LRS', + ], + ], $data['value'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * List Azure SQL servers. + * + * @param string $resourceGroupName Resource group name (optional) + * + * @return array, + * properties: array{ + * administratorLogin: string, + * version: string, + * state: string, + * fullyQualifiedDomainName: string, + * }, + * }> + */ + public function listSqlServers(string $resourceGroupName = ''): array + { + try { + $params = [ + 'api-version' => '2021-02-01-preview', + ]; + + $headers = ['Content-Type' => 'application/json']; + if ($this->accessToken) { + $headers['Authorization'] = 'Bearer '.$this->accessToken; + } + + $url = $resourceGroupName + ? "https://management.azure.com/subscriptions/{$this->subscriptionId}/resourceGroups/{$resourceGroupName}/providers/Microsoft.Sql/servers" + : "https://management.azure.com/subscriptions/{$this->subscriptionId}/providers/Microsoft.Sql/servers"; + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($server) => [ + 'id' => $server['id'], + 'name' => $server['name'], + 'type' => $server['type'], + 'location' => $server['location'], + 'tags' => $server['tags'] ?? [], + 'properties' => [ + 'administratorLogin' => $server['properties']['administratorLogin'], + 'version' => $server['properties']['version'], + 'state' => $server['properties']['state'], + 'fullyQualifiedDomainName' => $server['properties']['fullyQualifiedDomainName'], + ], + ], $data['value'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * List Azure resource groups. + * + * @param string $filter OData filter expression + * + * @return array, + * properties: array{ + * provisioningState: string, + * }, + * }> + */ + public function listResourceGroups(string $filter = ''): array + { + try { + $params = [ + 'api-version' => '2021-04-01', + ]; + + if ($filter) { + $params['$filter'] = $filter; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->accessToken) { + $headers['Authorization'] = 'Bearer '.$this->accessToken; + } + + $response = $this->httpClient->request('GET', "https://management.azure.com/subscriptions/{$this->subscriptionId}/resourcegroups", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($rg) => [ + 'id' => $rg['id'], + 'name' => $rg['name'], + 'type' => $rg['type'], + 'location' => $rg['location'], + 'tags' => $rg['tags'] ?? [], + 'properties' => [ + 'provisioningState' => $rg['properties']['provisioningState'], + ], + ], $data['value'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * List Azure web apps. + * + * @param string $resourceGroupName Resource group name (optional) + * + * @return array, + * properties: array{ + * state: string, + * hostNames: array, + * repositorySiteName: string, + * usageState: string, + * enabled: bool, + * enabledHostNames: array, + * availabilityState: string, + * serverFarmId: string, + * reserved: bool, + * isXenon: bool, + * hyperV: bool, + * lastModifiedTimeUtc: string, + * siteConfig: array, + * trafficManagerHostNames: array, + * scmSiteAlsoStopped: bool, + * targetSwapSlot: string, + * hostingEnvironmentProfile: array|null, + * clientAffinityEnabled: bool, + * clientCertEnabled: bool, + * clientCertMode: string, + * clientCertExclusionPaths: string, + * hostNamesDisabled: bool, + * customDomainVerificationId: string, + * outboundIpAddresses: string, + * possibleOutboundIpAddresses: string, + * containerSize: int, + * dailyMemoryTimeQuota: int, + * suspendedTill: string, + * maxNumberOfWorkers: int, + * cloningInfo: array|null, + * resourceGroup: string, + * isDefaultContainer: bool, + * defaultHostName: string, + * slotSwapStatus: array|null, + * httpsOnly: bool, + * redundancyMode: string, + * storageAccountRequired: bool, + * keyVaultReferenceIdentity: string, + * virtualNetworkSubnetId: string, + * }, + * }> + */ + public function listWebApps(string $resourceGroupName = ''): array + { + try { + $params = [ + 'api-version' => '2021-02-01', + ]; + + $headers = ['Content-Type' => 'application/json']; + if ($this->accessToken) { + $headers['Authorization'] = 'Bearer '.$this->accessToken; + } + + $url = $resourceGroupName + ? "https://management.azure.com/subscriptions/{$this->subscriptionId}/resourceGroups/{$resourceGroupName}/providers/Microsoft.Web/sites" + : "https://management.azure.com/subscriptions/{$this->subscriptionId}/providers/Microsoft.Web/sites"; + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($app) => [ + 'id' => $app['id'], + 'name' => $app['name'], + 'type' => $app['type'], + 'location' => $app['location'], + 'tags' => $app['tags'] ?? [], + 'properties' => [ + 'state' => $app['properties']['state'], + 'hostNames' => $app['properties']['hostNames'], + 'repositorySiteName' => $app['properties']['repositorySiteName'], + 'usageState' => $app['properties']['usageState'], + 'enabled' => $app['properties']['enabled'], + 'enabledHostNames' => $app['properties']['enabledHostNames'], + 'availabilityState' => $app['properties']['availabilityState'], + 'serverFarmId' => $app['properties']['serverFarmId'], + 'reserved' => $app['properties']['reserved'], + 'isXenon' => $app['properties']['isXenon'], + 'hyperV' => $app['properties']['hyperV'], + 'lastModifiedTimeUtc' => $app['properties']['lastModifiedTimeUtc'], + 'siteConfig' => $app['properties']['siteConfig'] ?? [], + 'trafficManagerHostNames' => $app['properties']['trafficManagerHostNames'], + 'scmSiteAlsoStopped' => $app['properties']['scmSiteAlsoStopped'], + 'targetSwapSlot' => $app['properties']['targetSwapSlot'] ?? '', + 'hostingEnvironmentProfile' => $app['properties']['hostingEnvironmentProfile'] ?? null, + 'clientAffinityEnabled' => $app['properties']['clientAffinityEnabled'], + 'clientCertEnabled' => $app['properties']['clientCertEnabled'], + 'clientCertMode' => $app['properties']['clientCertMode'], + 'clientCertExclusionPaths' => $app['properties']['clientCertExclusionPaths'] ?? '', + 'hostNamesDisabled' => $app['properties']['hostNamesDisabled'], + 'customDomainVerificationId' => $app['properties']['customDomainVerificationId'] ?? '', + 'outboundIpAddresses' => $app['properties']['outboundIpAddresses'] ?? '', + 'possibleOutboundIpAddresses' => $app['properties']['possibleOutboundIpAddresses'] ?? '', + 'containerSize' => $app['properties']['containerSize'] ?? 0, + 'dailyMemoryTimeQuota' => $app['properties']['dailyMemoryTimeQuota'] ?? 0, + 'suspendedTill' => $app['properties']['suspendedTill'] ?? '', + 'maxNumberOfWorkers' => $app['properties']['maxNumberOfWorkers'] ?? 0, + 'cloningInfo' => $app['properties']['cloningInfo'] ?? null, + 'resourceGroup' => $app['properties']['resourceGroup'], + 'isDefaultContainer' => $app['properties']['isDefaultContainer'], + 'defaultHostName' => $app['properties']['defaultHostName'], + 'slotSwapStatus' => $app['properties']['slotSwapStatus'] ?? null, + 'httpsOnly' => $app['properties']['httpsOnly'], + 'redundancyMode' => $app['properties']['redundancyMode'] ?? 'None', + 'storageAccountRequired' => $app['properties']['storageAccountRequired'] ?? false, + 'keyVaultReferenceIdentity' => $app['properties']['keyVaultReferenceIdentity'] ?? '', + 'virtualNetworkSubnetId' => $app['properties']['virtualNetworkSubnetId'] ?? '', + ], + ], $data['value'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * List Azure Functions. + * + * @param string $resourceGroupName Resource group name (optional) + * + * @return array, + * properties: array{ + * state: string, + * hostNames: array, + * repositorySiteName: string, + * usageState: string, + * enabled: bool, + * enabledHostNames: array, + * availabilityState: string, + * serverFarmId: string, + * reserved: bool, + * isXenon: bool, + * hyperV: bool, + * lastModifiedTimeUtc: string, + * siteConfig: array, + * trafficManagerHostNames: array, + * scmSiteAlsoStopped: bool, + * targetSwapSlot: string, + * hostingEnvironmentProfile: array|null, + * clientAffinityEnabled: bool, + * clientCertEnabled: bool, + * clientCertMode: string, + * clientCertExclusionPaths: string, + * hostNamesDisabled: bool, + * customDomainVerificationId: string, + * outboundIpAddresses: string, + * possibleOutboundIpAddresses: string, + * containerSize: int, + * dailyMemoryTimeQuota: int, + * suspendedTill: string, + * maxNumberOfWorkers: int, + * cloningInfo: array|null, + * resourceGroup: string, + * isDefaultContainer: bool, + * defaultHostName: string, + * slotSwapStatus: array|null, + * httpsOnly: bool, + * redundancyMode: string, + * storageAccountRequired: bool, + * keyVaultReferenceIdentity: string, + * virtualNetworkSubnetId: string, + * }, + * }> + */ + public function listFunctions(string $resourceGroupName = ''): array + { + try { + $params = [ + 'api-version' => '2021-02-01', + ]; + + $headers = ['Content-Type' => 'application/json']; + if ($this->accessToken) { + $headers['Authorization'] = 'Bearer '.$this->accessToken; + } + + $url = $resourceGroupName + ? "https://management.azure.com/subscriptions/{$this->subscriptionId}/resourceGroups/{$resourceGroupName}/providers/Microsoft.Web/sites" + : "https://management.azure.com/subscriptions/{$this->subscriptionId}/providers/Microsoft.Web/sites"; + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => $headers, + 'query' => array_merge($this->options, array_merge($params, ['kind' => 'functionapp'])), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($function) => [ + 'id' => $function['id'], + 'name' => $function['name'], + 'type' => $function['type'], + 'location' => $function['location'], + 'tags' => $function['tags'] ?? [], + 'properties' => [ + 'state' => $function['properties']['state'], + 'hostNames' => $function['properties']['hostNames'], + 'repositorySiteName' => $function['properties']['repositorySiteName'], + 'usageState' => $function['properties']['usageState'], + 'enabled' => $function['properties']['enabled'], + 'enabledHostNames' => $function['properties']['enabledHostNames'], + 'availabilityState' => $function['properties']['availabilityState'], + 'serverFarmId' => $function['properties']['serverFarmId'], + 'reserved' => $function['properties']['reserved'], + 'isXenon' => $function['properties']['isXenon'], + 'hyperV' => $function['properties']['hyperV'], + 'lastModifiedTimeUtc' => $function['properties']['lastModifiedTimeUtc'], + 'siteConfig' => $function['properties']['siteConfig'] ?? [], + 'trafficManagerHostNames' => $function['properties']['trafficManagerHostNames'], + 'scmSiteAlsoStopped' => $function['properties']['scmSiteAlsoStopped'], + 'targetSwapSlot' => $function['properties']['targetSwapSlot'] ?? '', + 'hostingEnvironmentProfile' => $function['properties']['hostingEnvironmentProfile'] ?? null, + 'clientAffinityEnabled' => $function['properties']['clientAffinityEnabled'], + 'clientCertEnabled' => $function['properties']['clientCertEnabled'], + 'clientCertMode' => $function['properties']['clientCertMode'], + 'clientCertExclusionPaths' => $function['properties']['clientCertExclusionPaths'] ?? '', + 'hostNamesDisabled' => $function['properties']['hostNamesDisabled'], + 'customDomainVerificationId' => $function['properties']['customDomainVerificationId'] ?? '', + 'outboundIpAddresses' => $function['properties']['outboundIpAddresses'] ?? '', + 'possibleOutboundIpAddresses' => $function['properties']['possibleOutboundIpAddresses'] ?? '', + 'containerSize' => $function['properties']['containerSize'] ?? 0, + 'dailyMemoryTimeQuota' => $function['properties']['dailyMemoryTimeQuota'] ?? 0, + 'suspendedTill' => $function['properties']['suspendedTill'] ?? '', + 'maxNumberOfWorkers' => $function['properties']['maxNumberOfWorkers'] ?? 0, + 'cloningInfo' => $function['properties']['cloningInfo'] ?? null, + 'resourceGroup' => $function['properties']['resourceGroup'], + 'isDefaultContainer' => $function['properties']['isDefaultContainer'], + 'defaultHostName' => $function['properties']['defaultHostName'], + 'slotSwapStatus' => $function['properties']['slotSwapStatus'] ?? null, + 'httpsOnly' => $function['properties']['httpsOnly'], + 'redundancyMode' => $function['properties']['redundancyMode'] ?? 'None', + 'storageAccountRequired' => $function['properties']['storageAccountRequired'] ?? false, + 'keyVaultReferenceIdentity' => $function['properties']['keyVaultReferenceIdentity'] ?? '', + 'virtualNetworkSubnetId' => $function['properties']['virtualNetworkSubnetId'] ?? '', + ], + ], $data['value'] ?? []); + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/AzureAi.php b/src/agent/src/Toolbox/Tool/AzureAi.php new file mode 100644 index 000000000..c41ff7854 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/AzureAi.php @@ -0,0 +1,948 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('azure_ai_analyze', 'Tool that analyzes content using Azure AI Services')] +#[AsTool('azure_ai_translate', 'Tool that translates text', method: 'translate')] +#[AsTool('azure_ai_summarize', 'Tool that summarizes content', method: 'summarize')] +#[AsTool('azure_ai_extract_keywords', 'Tool that extracts keywords', method: 'extractKeywords')] +#[AsTool('azure_ai_sentiment_analysis', 'Tool that analyzes sentiment', method: 'sentimentAnalysis')] +#[AsTool('azure_ai_entity_recognition', 'Tool that recognizes entities', method: 'entityRecognition')] +#[AsTool('azure_ai_language_detection', 'Tool that detects language', method: 'languageDetection')] +#[AsTool('azure_ai_text_analytics', 'Tool that performs text analytics', method: 'textAnalytics')] +final readonly class AzureAi +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $subscriptionKey, + private string $endpoint, + private string $region = 'westus2', + private array $options = [], + ) { + } + + /** + * Analyze content using Azure AI Services. + * + * @param string $content Content to analyze + * @param array $analysisOptions Analysis options + * @param array $context Analysis context + * + * @return array{ + * success: bool, + * analysis: array{ + * content: string, + * analysis_options: array, + * results: array{ + * sentiment: array{ + * score: float, + * label: string, + * confidence: float, + * }, + * entities: array, + * key_phrases: array, + * language: array{ + * name: string, + * iso6391_name: string, + * confidence: float, + * }, + * pii_entities: array, + * }, + * insights: array, + * recommendations: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $content, + array $analysisOptions = [], + array $context = [], + ): array { + try { + $requestData = [ + 'documents' => [ + [ + 'id' => '1', + 'text' => $content, + 'language' => $context['language'] ?? 'en', + ], + ], + 'analysis_options' => $analysisOptions, + ]; + + $response = $this->httpClient->request('POST', "{$this->endpoint}/text/analytics/v3.1/analyze", [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->subscriptionKey, + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $document = $responseData['documents'][0] ?? []; + $errors = $responseData['errors'] ?? []; + + return [ + 'success' => empty($errors), + 'analysis' => [ + 'content' => $content, + 'analysis_options' => $analysisOptions, + 'results' => [ + 'sentiment' => [ + 'score' => $document['sentiment'] ?? 'neutral', + 'label' => $this->getSentimentLabel($document['confidenceScores'] ?? []), + 'confidence' => $document['confidenceScores']['positive'] ?? 0.0, + ], + 'entities' => array_map(fn ($entity) => [ + 'text' => $entity['text'] ?? '', + 'category' => $entity['category'] ?? '', + 'subcategory' => $entity['subcategory'] ?? '', + 'confidence' => $entity['confidenceScore'] ?? 0.0, + 'offset' => $entity['offset'] ?? 0, + 'length' => $entity['length'] ?? 0, + ], $document['entities'] ?? []), + 'key_phrases' => $document['keyPhrases'] ?? [], + 'language' => [ + 'name' => $document['detectedLanguage']['name'] ?? '', + 'iso6391_name' => $document['detectedLanguage']['iso6391Name'] ?? '', + 'confidence' => $document['detectedLanguage']['confidenceScore'] ?? 0.0, + ], + 'pii_entities' => array_map(fn ($entity) => [ + 'text' => $entity['text'] ?? '', + 'category' => $entity['category'] ?? '', + 'subcategory' => $entity['subcategory'] ?? '', + 'confidence' => $entity['confidenceScore'] ?? 0.0, + ], $document['redactedText'] ?? []), + ], + 'insights' => $this->generateInsights($document), + 'recommendations' => $this->generateRecommendations($document), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => implode(', ', array_map(fn ($error) => $error['message'], $errors)), + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'analysis' => [ + 'content' => $content, + 'analysis_options' => $analysisOptions, + 'results' => [ + 'sentiment' => [ + 'score' => 'neutral', + 'label' => 'neutral', + 'confidence' => 0.0, + ], + 'entities' => [], + 'key_phrases' => [], + 'language' => [ + 'name' => '', + 'iso6391_name' => '', + 'confidence' => 0.0, + ], + 'pii_entities' => [], + ], + 'insights' => [], + 'recommendations' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Translate text. + * + * @param string $text Text to translate + * @param string $targetLanguage Target language + * @param string $sourceLanguage Source language (optional) + * @param array $options Translation options + * + * @return array{ + * success: bool, + * translation: array{ + * text: string, + * source_language: string, + * target_language: string, + * translated_text: string, + * confidence: float, + * alternatives: array, + * detected_language: array{ + * language: string, + * score: float, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function translate( + string $text, + string $targetLanguage = 'en', + string $sourceLanguage = '', + array $options = [], + ): array { + try { + $requestData = [ + [ + 'text' => $text, + ], + ]; + + $query = ['to' => $targetLanguage]; + if ($sourceLanguage) { + $query['from'] = $sourceLanguage; + } + + $response = $this->httpClient->request('POST', "{$this->endpoint}/translate?api-version=3.0", [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->subscriptionKey, + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + 'query' => $query, + ] + $this->options); + + $responseData = $response->toArray(); + $translation = $responseData[0]['translations'][0] ?? []; + + return [ + 'success' => true, + 'translation' => [ + 'text' => $text, + 'source_language' => $sourceLanguage ?: $translation['detectedLanguage']['language'] ?? '', + 'target_language' => $targetLanguage, + 'translated_text' => $translation['text'] ?? '', + 'confidence' => $translation['confidenceScore'] ?? 0.0, + 'alternatives' => array_map(fn ($alt) => [ + 'text' => $alt['text'] ?? '', + 'confidence' => $alt['confidenceScore'] ?? 0.0, + ], $translation['alternatives'] ?? []), + 'detected_language' => [ + 'language' => $translation['detectedLanguage']['language'] ?? '', + 'score' => $translation['detectedLanguage']['score'] ?? 0.0, + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'translation' => [ + 'text' => $text, + 'source_language' => $sourceLanguage, + 'target_language' => $targetLanguage, + 'translated_text' => '', + 'confidence' => 0.0, + 'alternatives' => [], + 'detected_language' => [ + 'language' => '', + 'score' => 0.0, + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Summarize content. + * + * @param string $content Content to summarize + * @param int $maxSentences Maximum number of sentences + * @param string $summaryType Type of summary + * @param array $options Summary options + * + * @return array{ + * success: bool, + * summary: array{ + * content: string, + * max_sentences: int, + * summary_type: string, + * summary_text: string, + * summary_sentences: array, + * key_points: array, + * confidence: float, + * compression_ratio: float, + * }, + * processingTime: float, + * error: string, + * } + */ + public function summarize( + string $content, + int $maxSentences = 3, + string $summaryType = 'extractive', + array $options = [], + ): array { + try { + $requestData = [ + 'documents' => [ + [ + 'id' => '1', + 'text' => $content, + ], + ], + 'summary_type' => $summaryType, + 'max_sentences' => max(1, min($maxSentences, 10)), + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->endpoint}/text/analytics/v3.1/abstractive/summarize", [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->subscriptionKey, + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $summary = $responseData['documents'][0]['summaries'][0] ?? []; + + return [ + 'success' => true, + 'summary' => [ + 'content' => $content, + 'max_sentences' => $maxSentences, + 'summary_type' => $summaryType, + 'summary_text' => $summary['text'] ?? '', + 'summary_sentences' => explode('. ', $summary['text'] ?? ''), + 'key_points' => $summary['keyPoints'] ?? [], + 'confidence' => $summary['confidence'] ?? 0.0, + 'compression_ratio' => $this->calculateCompressionRatio($content, $summary['text'] ?? ''), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'summary' => [ + 'content' => $content, + 'max_sentences' => $maxSentences, + 'summary_type' => $summaryType, + 'summary_text' => '', + 'summary_sentences' => [], + 'key_points' => [], + 'confidence' => 0.0, + 'compression_ratio' => 0.0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Extract keywords. + * + * @param string $content Content to extract keywords from + * @param int $maxKeywords Maximum number of keywords + * @param array $options Extraction options + * + * @return array{ + * success: bool, + * keywords: array{ + * content: string, + * max_keywords: int, + * extracted_keywords: array, + * total_keywords: int, + * extraction_confidence: float, + * }, + * processingTime: float, + * error: string, + * } + */ + public function extractKeywords( + string $content, + int $maxKeywords = 10, + array $options = [], + ): array { + try { + $requestData = [ + 'documents' => [ + [ + 'id' => '1', + 'text' => $content, + ], + ], + 'max_keywords' => max(1, min($maxKeywords, 50)), + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->endpoint}/text/analytics/v3.1/keyPhrases", [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->subscriptionKey, + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $document = $responseData['documents'][0] ?? []; + $keyPhrases = $document['keyPhrases'] ?? []; + + return [ + 'success' => true, + 'keywords' => [ + 'content' => $content, + 'max_keywords' => $maxKeywords, + 'extracted_keywords' => array_map(fn ($phrase, $index) => [ + 'keyword' => $phrase, + 'relevance_score' => 1.0 - ($index / \count($keyPhrases)), + 'frequency' => substr_count(strtolower($content), strtolower($phrase)), + 'category' => $this->categorizeKeyword($phrase), + ], \array_slice($keyPhrases, 0, $maxKeywords)), + 'total_keywords' => \count($keyPhrases), + 'extraction_confidence' => $document['confidence'] ?? 0.0, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'keywords' => [ + 'content' => $content, + 'max_keywords' => $maxKeywords, + 'extracted_keywords' => [], + 'total_keywords' => 0, + 'extraction_confidence' => 0.0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze sentiment. + * + * @param string $content Content to analyze + * @param array $options Sentiment analysis options + * + * @return array{ + * success: bool, + * sentiment: array{ + * content: string, + * sentiment_score: float, + * sentiment_label: string, + * confidence_scores: array{ + * positive: float, + * neutral: float, + * negative: float, + * }, + * sentence_sentiments: array, + * overall_sentiment: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function sentimentAnalysis( + string $content, + array $options = [], + ): array { + try { + $requestData = [ + 'documents' => [ + [ + 'id' => '1', + 'text' => $content, + ], + ], + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->endpoint}/text/analytics/v3.1/sentiment", [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->subscriptionKey, + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $document = $responseData['documents'][0] ?? []; + $sentences = $document['sentences'] ?? []; + + return [ + 'success' => true, + 'sentiment' => [ + 'content' => $content, + 'sentiment_score' => $this->calculateSentimentScore($document['confidenceScores'] ?? []), + 'sentiment_label' => $document['sentiment'] ?? 'neutral', + 'confidence_scores' => [ + 'positive' => $document['confidenceScores']['positive'] ?? 0.0, + 'neutral' => $document['confidenceScores']['neutral'] ?? 0.0, + 'negative' => $document['confidenceScores']['negative'] ?? 0.0, + ], + 'sentence_sentiments' => array_map(fn ($sentence) => [ + 'text' => $sentence['text'] ?? '', + 'sentiment' => $sentence['sentiment'] ?? 'neutral', + 'confidence' => $sentence['confidenceScores']['positive'] ?? 0.0, + ], $sentences), + 'overall_sentiment' => $document['sentiment'] ?? 'neutral', + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'sentiment' => [ + 'content' => $content, + 'sentiment_score' => 0.0, + 'sentiment_label' => 'neutral', + 'confidence_scores' => [ + 'positive' => 0.0, + 'neutral' => 1.0, + 'negative' => 0.0, + ], + 'sentence_sentiments' => [], + 'overall_sentiment' => 'neutral', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Recognize entities. + * + * @param string $content Content to analyze + * @param array $entityTypes Entity types to recognize + * @param array $options Recognition options + * + * @return array{ + * success: bool, + * entities: array{ + * content: string, + * entity_types: array, + * recognized_entities: array, + * entity_categories: array, + * total_entities: int, + * }, + * processingTime: float, + * error: string, + * } + */ + public function entityRecognition( + string $content, + array $entityTypes = [], + array $options = [], + ): array { + try { + $requestData = [ + 'documents' => [ + [ + 'id' => '1', + 'text' => $content, + ], + ], + 'entity_types' => $entityTypes, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->endpoint}/text/analytics/v3.1/entities/recognition/general", [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->subscriptionKey, + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $document = $responseData['documents'][0] ?? []; + $entities = $document['entities'] ?? []; + + return [ + 'success' => true, + 'entities' => [ + 'content' => $content, + 'entity_types' => $entityTypes, + 'recognized_entities' => array_map(fn ($entity) => [ + 'text' => $entity['text'] ?? '', + 'category' => $entity['category'] ?? '', + 'subcategory' => $entity['subcategory'] ?? '', + 'confidence' => $entity['confidenceScore'] ?? 0.0, + 'offset' => $entity['offset'] ?? 0, + 'length' => $entity['length'] ?? 0, + 'wikipedia_url' => $entity['wikipediaUrl'] ?? '', + ], $entities), + 'entity_categories' => $this->countEntityCategories($entities), + 'total_entities' => \count($entities), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'entities' => [ + 'content' => $content, + 'entity_types' => $entityTypes, + 'recognized_entities' => [], + 'entity_categories' => [], + 'total_entities' => 0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Detect language. + * + * @param string $content Content to analyze + * @param array $options Detection options + * + * @return array{ + * success: bool, + * language_detection: array{ + * content: string, + * detected_language: array{ + * name: string, + * iso6391_name: string, + * confidence: float, + * }, + * alternative_languages: array, + * detection_confidence: float, + * }, + * processingTime: float, + * error: string, + * } + */ + public function languageDetection( + string $content, + array $options = [], + ): array { + try { + $requestData = [ + 'documents' => [ + [ + 'id' => '1', + 'text' => $content, + ], + ], + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->endpoint}/text/analytics/v3.1/languages", [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->subscriptionKey, + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $document = $responseData['documents'][0] ?? []; + $detectedLanguage = $document['detectedLanguage'] ?? []; + + return [ + 'success' => true, + 'language_detection' => [ + 'content' => $content, + 'detected_language' => [ + 'name' => $detectedLanguage['name'] ?? '', + 'iso6391_name' => $detectedLanguage['iso6391Name'] ?? '', + 'confidence' => $detectedLanguage['confidenceScore'] ?? 0.0, + ], + 'alternative_languages' => array_map(fn ($alt) => [ + 'name' => $alt['name'] ?? '', + 'iso6391_name' => $alt['iso6391Name'] ?? '', + 'confidence' => $alt['confidenceScore'] ?? 0.0, + ], $detectedLanguage['alternatives'] ?? []), + 'detection_confidence' => $detectedLanguage['confidenceScore'] ?? 0.0, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'language_detection' => [ + 'content' => $content, + 'detected_language' => [ + 'name' => '', + 'iso6391_name' => '', + 'confidence' => 0.0, + ], + 'alternative_languages' => [], + 'detection_confidence' => 0.0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Perform comprehensive text analytics. + * + * @param string $content Content to analyze + * @param array $analyticsOptions Analytics options + * + * @return array{ + * success: bool, + * text_analytics: array{ + * content: string, + * analytics_options: array, + * comprehensive_analysis: array{ + * sentiment: array, + * entities: array>, + * key_phrases: array, + * language: array, + * pii_entities: array>, + * }, + * insights: array, + * recommendations: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function textAnalytics( + string $content, + array $analyticsOptions = [], + ): array { + try { + // Perform comprehensive analysis by combining multiple Azure AI services + $sentimentResult = $this->sentimentAnalysis($content); + $entitiesResult = $this->entityRecognition($content); + $keywordsResult = $this->extractKeywords($content); + $languageResult = $this->languageDetection($content); + + return [ + 'success' => $sentimentResult['success'] && $entitiesResult['success'], + 'text_analytics' => [ + 'content' => $content, + 'analytics_options' => $analyticsOptions, + 'comprehensive_analysis' => [ + 'sentiment' => $sentimentResult['sentiment'], + 'entities' => $entitiesResult['entities']['recognized_entities'], + 'key_phrases' => $keywordsResult['keywords']['extracted_keywords'], + 'language' => $languageResult['language_detection']['detected_language'], + 'pii_entities' => [], + ], + 'insights' => $this->generateComprehensiveInsights([ + 'sentiment' => $sentimentResult['sentiment'], + 'entities' => $entitiesResult['entities'], + 'keywords' => $keywordsResult['keywords'], + 'language' => $languageResult['language_detection'], + ]), + 'recommendations' => $this->generateComprehensiveRecommendations([ + 'sentiment' => $sentimentResult['sentiment'], + 'entities' => $entitiesResult['entities'], + 'keywords' => $keywordsResult['keywords'], + 'language' => $languageResult['language_detection'], + ]), + ], + 'processingTime' => max( + $sentimentResult['processingTime'], + $entitiesResult['processingTime'], + $keywordsResult['processingTime'], + $languageResult['processingTime'] + ), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'text_analytics' => [ + 'content' => $content, + 'analytics_options' => $analyticsOptions, + 'comprehensive_analysis' => [ + 'sentiment' => [], + 'entities' => [], + 'key_phrases' => [], + 'language' => [], + 'pii_entities' => [], + ], + 'insights' => [], + 'recommendations' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Helper methods. + */ + private function getSentimentLabel(array $confidenceScores): string + { + $maxScore = max($confidenceScores); + + return array_search($maxScore, $confidenceScores) ?: 'neutral'; + } + + private function calculateSentimentScore(array $confidenceScores): float + { + $positive = $confidenceScores['positive'] ?? 0.0; + $negative = $confidenceScores['negative'] ?? 0.0; + + return $positive - $negative; + } + + private function calculateCompressionRatio(string $original, string $summary): float + { + if (empty($original)) { + return 0.0; + } + + return \strlen($summary) / \strlen($original); + } + + private function categorizeKeyword(string $keyword): string + { + $techKeywords = ['software', 'technology', 'computer', 'programming', 'development']; + $businessKeywords = ['business', 'management', 'strategy', 'marketing', 'sales']; + $scienceKeywords = ['research', 'study', 'analysis', 'data', 'science']; + + $keywordLower = strtolower($keyword); + + if (array_intersect(explode(' ', $keywordLower), $techKeywords)) { + return 'technology'; + } elseif (array_intersect(explode(' ', $keywordLower), $businessKeywords)) { + return 'business'; + } elseif (array_intersect(explode(' ', $keywordLower), $scienceKeywords)) { + return 'science'; + } + + return 'general'; + } + + private function countEntityCategories(array $entities): array + { + $categories = []; + foreach ($entities as $entity) { + $category = $entity['category'] ?? 'unknown'; + $categories[$category] = ($categories[$category] ?? 0) + 1; + } + + return $categories; + } + + private function generateInsights(array $document): array + { + $insights = []; + + if (!empty($document['entities'])) { + $insights[] = 'Content contains '.\count($document['entities']).' named entities'; + } + + if (!empty($document['keyPhrases'])) { + $insights[] = 'Key phrases identified: '.implode(', ', \array_slice($document['keyPhrases'], 0, 3)); + } + + return $insights; + } + + private function generateRecommendations(array $document): array + { + $recommendations = []; + + if (empty($document['keyPhrases'])) { + $recommendations[] = 'Consider adding more specific keywords to improve content discoverability'; + } + + if (empty($document['entities'])) { + $recommendations[] = 'Content could benefit from more specific named entities'; + } + + return $recommendations; + } + + private function generateComprehensiveInsights(array $results): array + { + $insights = []; + + if ('neutral' !== $results['sentiment']['sentiment_label']) { + $insights[] = 'Content has '.$results['sentiment']['sentiment_label'].' sentiment'; + } + + if (!empty($results['entities']['recognized_entities'])) { + $insights[] = 'Identified '.$results['entities']['total_entities'].' entities'; + } + + return $insights; + } + + private function generateComprehensiveRecommendations(array $results): array + { + $recommendations = []; + + if ('negative' === $results['sentiment']['sentiment_label']) { + $recommendations[] = 'Consider revising content to improve sentiment'; + } + + if ($results['entities']['total_entities'] < 3) { + $recommendations[] = 'Add more specific entities to enhance content richness'; + } + + return $recommendations; + } +} diff --git a/src/agent/src/Toolbox/Tool/AzureCognitive.php b/src/agent/src/Toolbox/Tool/AzureCognitive.php new file mode 100644 index 000000000..1ce759498 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/AzureCognitive.php @@ -0,0 +1,1033 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('azure_cognitive_analyze_image', 'Tool that analyzes images using Azure Cognitive Services')] +#[AsTool('azure_cognitive_recognize_text', 'Tool that recognizes text in images', method: 'recognizeText')] +#[AsTool('azure_cognitive_detect_faces', 'Tool that detects faces in images', method: 'detectFaces')] +#[AsTool('azure_cognitive_analyze_content', 'Tool that analyzes content for moderation', method: 'analyzeContent')] +#[AsTool('azure_cognitive_translate_text', 'Tool that translates text using Azure Translator', method: 'translateText')] +#[AsTool('azure_cognitive_speech_to_text', 'Tool that converts speech to text', method: 'speechToText')] +#[AsTool('azure_cognitive_text_to_speech', 'Tool that converts text to speech', method: 'textToSpeech')] +#[AsTool('azure_cognitive_search_web', 'Tool that searches the web using Bing Search', method: 'searchWeb')] +final readonly class AzureCognitive +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $subscriptionKey, + private string $endpoint, + private string $region = 'westus2', + private array $options = [], + ) { + } + + /** + * Analyze images using Azure Cognitive Services. + * + * @param string $imageUrl URL or base64 encoded image + * @param array $features Features to analyze + * @param array $details Details to include + * @param array $options Analysis options + * + * @return array{ + * success: bool, + * image_analysis: array{ + * image_url: string, + * features: array, + * details: array, + * analysis_results: array{ + * categories: array, + * }>, + * tags: array, + * objects: array, + * faces: array, + * }>, + * adult: array{ + * is_adult_content: bool, + * is_racy_content: bool, + * adult_score: float, + * racy_score: float, + * }, + * color: array{ + * dominant_color_foreground: string, + * dominant_color_background: string, + * dominant_colors: array, + * accent_color: string, + * }, + * description: array{ + * tags: array, + * captions: array, + * }, + * }, + * insights: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $imageUrl, + array $features = [], + array $details = [], + array $options = [], + ): array { + try { + $requestData = [ + 'url' => $imageUrl, + 'visualFeatures' => $features ?: ['Categories', 'Tags', 'Objects', 'Faces', 'Adult', 'Color', 'Description'], + 'details' => $details ?: ['Celebrities', 'Landmarks'], + 'language' => $options['language'] ?? 'en', + ]; + + $response = $this->httpClient->request('POST', "{$this->endpoint}/vision/v3.2/analyze", [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->subscriptionKey, + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'image_analysis' => [ + 'image_url' => $imageUrl, + 'features' => $features, + 'details' => $details, + 'analysis_results' => [ + 'categories' => array_map(fn ($category) => [ + 'name' => $category['name'] ?? '', + 'score' => $category['score'] ?? 0.0, + 'detail' => $category['detail'] ?? [], + ], $responseData['categories'] ?? []), + 'tags' => array_map(fn ($tag) => [ + 'name' => $tag['name'] ?? '', + 'confidence' => $tag['confidence'] ?? 0.0, + ], $responseData['tags'] ?? []), + 'objects' => array_map(fn ($object) => [ + 'object' => $object['object'] ?? '', + 'confidence' => $object['confidence'] ?? 0.0, + 'rectangle' => [ + 'x' => $object['rectangle']['x'] ?? 0, + 'y' => $object['rectangle']['y'] ?? 0, + 'w' => $object['rectangle']['w'] ?? 0, + 'h' => $object['rectangle']['h'] ?? 0, + ], + ], $responseData['objects'] ?? []), + 'faces' => array_map(fn ($face) => [ + 'age' => $face['age'] ?? 0, + 'gender' => $face['gender'] ?? '', + 'face_rectangle' => [ + 'left' => $face['faceRectangle']['left'] ?? 0, + 'top' => $face['faceRectangle']['top'] ?? 0, + 'width' => $face['faceRectangle']['width'] ?? 0, + 'height' => $face['faceRectangle']['height'] ?? 0, + ], + 'emotion' => $face['emotion'] ?? [], + ], $responseData['faces'] ?? []), + 'adult' => [ + 'is_adult_content' => $responseData['adult']['isAdultContent'] ?? false, + 'is_racy_content' => $responseData['adult']['isRacyContent'] ?? false, + 'adult_score' => $responseData['adult']['adultScore'] ?? 0.0, + 'racy_score' => $responseData['adult']['racyScore'] ?? 0.0, + ], + 'color' => [ + 'dominant_color_foreground' => $responseData['color']['dominantColorForeground'] ?? '', + 'dominant_color_background' => $responseData['color']['dominantColorBackground'] ?? '', + 'dominant_colors' => $responseData['color']['dominantColors'] ?? [], + 'accent_color' => $responseData['color']['accentColor'] ?? '', + ], + 'description' => [ + 'tags' => $responseData['description']['tags'] ?? [], + 'captions' => array_map(fn ($caption) => [ + 'text' => $caption['text'] ?? '', + 'confidence' => $caption['confidence'] ?? 0.0, + ], $responseData['description']['captions'] ?? []), + ], + ], + 'insights' => $this->generateImageInsights($responseData), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'image_analysis' => [ + 'image_url' => $imageUrl, + 'features' => $features, + 'details' => $details, + 'analysis_results' => [ + 'categories' => [], + 'tags' => [], + 'objects' => [], + 'faces' => [], + 'adult' => [ + 'is_adult_content' => false, + 'is_racy_content' => false, + 'adult_score' => 0.0, + 'racy_score' => 0.0, + ], + 'color' => [ + 'dominant_color_foreground' => '', + 'dominant_color_background' => '', + 'dominant_colors' => [], + 'accent_color' => '', + ], + 'description' => [ + 'tags' => [], + 'captions' => [], + ], + ], + 'insights' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Recognize text in images using OCR. + * + * @param string $imageUrl URL or base64 encoded image + * @param string $language Language hint + * @param array $options Recognition options + * + * @return array{ + * success: bool, + * text_recognition: array{ + * image_url: string, + * language: string, + * recognized_text: string, + * text_regions: array, + * confidence: float, + * }>, + * words: array, + * confidence: float, + * }>, + * lines: array, + * }>, + * }, + * processingTime: float, + * error: string, + * } + */ + public function recognizeText( + string $imageUrl, + string $language = 'en', + array $options = [], + ): array { + try { + $requestData = [ + 'url' => $imageUrl, + 'language' => $language, + 'detectOrientation' => $options['detect_orientation'] ?? true, + ]; + + $response = $this->httpClient->request('POST', "{$this->endpoint}/vision/v3.2/ocr", [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->subscriptionKey, + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $regions = $responseData['regions'] ?? []; + $allText = []; + + foreach ($regions as $region) { + foreach ($region['lines'] ?? [] as $line) { + $allText[] = $line['text'] ?? ''; + } + } + + return [ + 'success' => true, + 'text_recognition' => [ + 'image_url' => $imageUrl, + 'language' => $language, + 'recognized_text' => implode(' ', $allText), + 'text_regions' => array_map(fn ($region) => [ + 'text' => implode(' ', array_map(fn ($line) => $line['text'] ?? '', $region['lines'] ?? [])), + 'bounding_box' => array_map(fn ($point) => [ + 'x' => $point[0] ?? 0.0, + 'y' => $point[1] ?? 0.0, + ], $region['boundingBox'] ?? []), + 'confidence' => $region['confidence'] ?? 0.0, + ], $regions), + 'words' => $this->extractWords($regions), + 'lines' => $this->extractLines($regions), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'text_recognition' => [ + 'image_url' => $imageUrl, + 'language' => $language, + 'recognized_text' => '', + 'text_regions' => [], + 'words' => [], + 'lines' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Detect faces in images. + * + * @param string $imageUrl URL or base64 encoded image + * @param array $attributes Face attributes to detect + * @param array $options Detection options + * + * @return array{ + * success: bool, + * face_detection: array{ + * image_url: string, + * attributes: array, + * detected_faces: array, + * makeup: array{ + * eye_makeup: bool, + * lip_makeup: bool, + * }, + * accessories: array, + * blur: array{ + * blur_level: string, + * value: float, + * }, + * exposure: array{ + * exposure_level: string, + * value: float, + * }, + * noise: array{ + * noise_level: string, + * value: float, + * }, + * }, + * }>, + * total_faces: int, + * }, + * processingTime: float, + * error: string, + * } + */ + public function detectFaces( + string $imageUrl, + array $attributes = [], + array $options = [], + ): array { + try { + $requestData = [ + 'url' => $imageUrl, + 'returnFaceAttributes' => implode(',', $attributes ?: [ + 'age', 'gender', 'smile', 'facialHair', 'glasses', 'emotion', + 'hair', 'makeup', 'accessories', 'blur', 'exposure', 'noise', + ]), + 'returnFaceId' => $options['return_face_id'] ?? true, + 'returnFaceLandmarks' => $options['return_landmarks'] ?? false, + ]; + + $response = $this->httpClient->request('POST', "{$this->endpoint}/face/v1.0/detect", [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->subscriptionKey, + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'face_detection' => [ + 'image_url' => $imageUrl, + 'attributes' => $attributes, + 'detected_faces' => array_map(fn ($face) => [ + 'face_id' => $face['faceId'] ?? '', + 'face_rectangle' => [ + 'left' => $face['faceRectangle']['left'] ?? 0, + 'top' => $face['faceRectangle']['top'] ?? 0, + 'width' => $face['faceRectangle']['width'] ?? 0, + 'height' => $face['faceRectangle']['height'] ?? 0, + ], + 'face_attributes' => [ + 'age' => $face['faceAttributes']['age'] ?? 0.0, + 'gender' => $face['faceAttributes']['gender'] ?? '', + 'smile' => $face['faceAttributes']['smile'] ?? 0.0, + 'facial_hair' => [ + 'moustache' => $face['faceAttributes']['facialHair']['moustache'] ?? 0.0, + 'beard' => $face['faceAttributes']['facialHair']['beard'] ?? 0.0, + 'sideburns' => $face['faceAttributes']['facialHair']['sideburns'] ?? 0.0, + ], + 'glasses' => $face['faceAttributes']['glasses'] ?? '', + 'head_pose' => [ + 'pitch' => $face['faceAttributes']['headPose']['pitch'] ?? 0.0, + 'roll' => $face['faceAttributes']['headPose']['roll'] ?? 0.0, + 'yaw' => $face['faceAttributes']['headPose']['yaw'] ?? 0.0, + ], + 'emotion' => $face['faceAttributes']['emotion'] ?? [], + 'makeup' => [ + 'eye_makeup' => $face['faceAttributes']['makeup']['eyeMakeup'] ?? false, + 'lip_makeup' => $face['faceAttributes']['makeup']['lipMakeup'] ?? false, + ], + 'accessories' => array_map(fn ($accessory) => [ + 'type' => $accessory['type'] ?? '', + 'confidence' => $accessory['confidence'] ?? 0.0, + ], $face['faceAttributes']['accessories'] ?? []), + 'blur' => [ + 'blur_level' => $face['faceAttributes']['blur']['blurLevel'] ?? '', + 'value' => $face['faceAttributes']['blur']['value'] ?? 0.0, + ], + 'exposure' => [ + 'exposure_level' => $face['faceAttributes']['exposure']['exposureLevel'] ?? '', + 'value' => $face['faceAttributes']['exposure']['value'] ?? 0.0, + ], + 'noise' => [ + 'noise_level' => $face['faceAttributes']['noise']['noiseLevel'] ?? '', + 'value' => $face['faceAttributes']['noise']['value'] ?? 0.0, + ], + ], + ], $responseData), + 'total_faces' => \count($responseData), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'face_detection' => [ + 'image_url' => $imageUrl, + 'attributes' => $attributes, + 'detected_faces' => [], + 'total_faces' => 0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze content for moderation. + * + * @param string $content Content to analyze (text or image URL) + * @param string $contentType Type of content (text/image) + * @param array $options Moderation options + * + * @return array{ + * success: bool, + * content_moderation: array{ + * content: string, + * content_type: string, + * moderation_results: array{ + * is_adult_content: bool, + * is_racy_content: bool, + * adult_score: float, + * racy_score: float, + * categories: array, + * category_scores: array, + * terms: array, + * }, + * recommendations: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function analyzeContent( + string $content, + string $contentType = 'text', + array $options = [], + ): array { + try { + if ('image' === $contentType) { + $requestData = ['DataRepresentation' => 'URL', 'Value' => $content]; + $endpoint = "{$this->endpoint}/contentmoderator/moderate/v1.0/ProcessImage/Evaluate"; + } else { + $requestData = ['Text' => $content]; + $endpoint = "{$this->endpoint}/contentmoderator/moderate/v1.0/ProcessText/Screen"; + } + + $response = $this->httpClient->request('POST', $endpoint, [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->subscriptionKey, + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'content_moderation' => [ + 'content' => $content, + 'content_type' => $contentType, + 'moderation_results' => [ + 'is_adult_content' => $responseData['IsImageAdultClassified'] ?? false, + 'is_racy_content' => $responseData['IsImageRacyClassified'] ?? false, + 'adult_score' => $responseData['AdultClassificationScore'] ?? 0.0, + 'racy_score' => $responseData['RacyClassificationScore'] ?? 0.0, + 'categories' => $responseData['Categories'] ?? [], + 'category_scores' => $responseData['CategoryScores'] ?? [], + 'terms' => array_map(fn ($term) => [ + 'term' => $term['Term'] ?? '', + 'index' => $term['Index'] ?? 0, + 'list_id' => $term['ListId'] ?? 0, + ], $responseData['Terms'] ?? []), + ], + 'recommendations' => $this->generateModerationRecommendations($responseData), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'content_moderation' => [ + 'content' => $content, + 'content_type' => $contentType, + 'moderation_results' => [ + 'is_adult_content' => false, + 'is_racy_content' => false, + 'adult_score' => 0.0, + 'racy_score' => 0.0, + 'categories' => [], + 'category_scores' => [], + 'terms' => [], + ], + 'recommendations' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Translate text using Azure Translator. + * + * @param string $text Text to translate + * @param string $targetLanguage Target language code + * @param string $sourceLanguage Source language code (optional) + * @param array $options Translation options + * + * @return array{ + * success: bool, + * translation: array{ + * text: string, + * source_language: string, + * target_language: string, + * translated_text: string, + * detected_language: array{ + * language: string, + * score: float, + * }, + * alternatives: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function translateText( + string $text, + string $targetLanguage = 'en', + string $sourceLanguage = '', + array $options = [], + ): array { + try { + $requestData = [ + [ + 'Text' => $text, + ], + ]; + + $query = ['api-version' => '3.0', 'to' => $targetLanguage]; + if ($sourceLanguage) { + $query['from'] = $sourceLanguage; + } + + $response = $this->httpClient->request('POST', "{$this->endpoint}/translator/text/v3.0/translate", [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->subscriptionKey, + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + 'query' => $query, + ] + $this->options); + + $responseData = $response->toArray(); + $translation = $responseData[0]['translations'][0] ?? []; + + return [ + 'success' => true, + 'translation' => [ + 'text' => $text, + 'source_language' => $sourceLanguage ?: $translation['detectedLanguage']['language'] ?? '', + 'target_language' => $targetLanguage, + 'translated_text' => $translation['text'] ?? '', + 'detected_language' => [ + 'language' => $translation['detectedLanguage']['language'] ?? '', + 'score' => $translation['detectedLanguage']['score'] ?? 0.0, + ], + 'alternatives' => array_map(fn ($alt) => [ + 'text' => $alt['text'] ?? '', + 'confidence' => $alt['confidenceScore'] ?? 0.0, + ], $translation['alternatives'] ?? []), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'translation' => [ + 'text' => $text, + 'source_language' => $sourceLanguage, + 'target_language' => $targetLanguage, + 'translated_text' => '', + 'detected_language' => [ + 'language' => '', + 'score' => 0.0, + ], + 'alternatives' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Convert speech to text. + * + * @param string $audioData Base64 encoded audio data + * @param string $language Language code + * @param array $options Speech recognition options + * + * @return array{ + * success: bool, + * speech_recognition: array{ + * audio_data: string, + * language: string, + * recognized_text: string, + * confidence: float, + * duration: float, + * alternatives: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function speechToText( + string $audioData, + string $language = 'en-US', + array $options = [], + ): array { + try { + $response = $this->httpClient->request('POST', "{$this->endpoint}/speech/recognition/conversation/cognitiveservices/v1", [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->subscriptionKey, + 'Content-Type' => 'audio/wav', + 'Accept' => 'application/json', + ], + 'body' => base64_decode($audioData), + 'query' => [ + 'language' => $language, + 'format' => $options['format'] ?? 'detailed', + ], + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'speech_recognition' => [ + 'audio_data' => $audioData, + 'language' => $language, + 'recognized_text' => $responseData['DisplayText'] ?? '', + 'confidence' => $responseData['Confidence'] ?? 0.0, + 'duration' => $responseData['Duration'] ?? 0.0, + 'alternatives' => array_map(fn ($alt) => [ + 'text' => $alt['DisplayText'] ?? '', + 'confidence' => $alt['Confidence'] ?? 0.0, + ], $responseData['NBest'] ?? []), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'speech_recognition' => [ + 'audio_data' => $audioData, + 'language' => $language, + 'recognized_text' => '', + 'confidence' => 0.0, + 'duration' => 0.0, + 'alternatives' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Convert text to speech. + * + * @param string $text Text to convert to speech + * @param string $voice Voice to use + * @param array $options Speech synthesis options + * + * @return array{ + * success: bool, + * speech_synthesis: array{ + * text: string, + * voice: string, + * audio_data: string, + * audio_format: string, + * duration: float, + * word_timing: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function textToSpeech( + string $text, + string $voice = 'en-US-AriaNeural', + array $options = [], + ): array { + try { + $ssml = $this->createSsml($text, $voice, $options); + + $response = $this->httpClient->request('POST', "{$this->endpoint}/cognitiveservices/v1", [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->subscriptionKey, + 'Content-Type' => 'application/ssml+xml', + 'X-Microsoft-OutputFormat' => $options['audio_format'] ?? 'riff-16khz-16bit-mono-pcm', + ], + 'body' => $ssml, + ] + $this->options); + + $audioData = base64_encode($response->getContent()); + + return [ + 'success' => true, + 'speech_synthesis' => [ + 'text' => $text, + 'voice' => $voice, + 'audio_data' => $audioData, + 'audio_format' => $options['audio_format'] ?? 'riff-16khz-16bit-mono-pcm', + 'duration' => $this->estimateAudioDuration($text), + 'word_timing' => [], + ], + 'processingTime' => 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'speech_synthesis' => [ + 'text' => $text, + 'voice' => $voice, + 'audio_data' => '', + 'audio_format' => $options['audio_format'] ?? 'riff-16khz-16bit-mono-pcm', + 'duration' => 0.0, + 'word_timing' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search the web using Bing Search. + * + * @param string $query Search query + * @param int $count Number of results to return + * @param array $options Search options + * + * @return array{ + * success: bool, + * web_search: array{ + * query: string, + * count: int, + * search_results: array, + * total_results: int, + * search_suggestions: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function searchWeb( + string $query, + int $count = 10, + array $options = [], + ): array { + try { + $response = $this->httpClient->request('GET', "{$this->endpoint}/bing/v7.0/search", [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->subscriptionKey, + ], + 'query' => [ + 'q' => $query, + 'count' => min($count, 50), + 'offset' => $options['offset'] ?? 0, + 'mkt' => $options['market'] ?? 'en-US', + 'safesearch' => $options['safe_search'] ?? 'Moderate', + ], + ] + $this->options); + + $responseData = $response->toArray(); + $webPages = $responseData['webPages']['value'] ?? []; + + return [ + 'success' => true, + 'web_search' => [ + 'query' => $query, + 'count' => $count, + 'search_results' => array_map(fn ($result) => [ + 'name' => $result['name'] ?? '', + 'url' => $result['url'] ?? '', + 'display_url' => $result['displayUrl'] ?? '', + 'snippet' => $result['snippet'] ?? '', + 'date_published' => $result['dateLastCrawled'] ?? '', + ], \array_slice($webPages, 0, $count)), + 'total_results' => $responseData['webPages']['totalEstimatedMatches'] ?? 0, + 'search_suggestions' => array_map(fn ($suggestion) => $suggestion['text'] ?? '', $responseData['queryContext']['alteredQuery'] ?? []), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'web_search' => [ + 'query' => $query, + 'count' => $count, + 'search_results' => [], + 'total_results' => 0, + 'search_suggestions' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Helper methods. + */ + private function generateImageInsights(array $responseData): array + { + $insights = []; + + if (!empty($responseData['faces'])) { + $insights[] = 'Image contains '.\count($responseData['faces']).' face(s)'; + } + + if (!empty($responseData['objects'])) { + $insights[] = 'Detected objects: '.implode(', ', array_map(fn ($obj) => $obj['object'], $responseData['objects'])); + } + + return $insights; + } + + private function extractWords(array $regions): array + { + $words = []; + foreach ($regions as $region) { + foreach ($region['lines'] ?? [] as $line) { + foreach ($line['words'] ?? [] as $word) { + $words[] = [ + 'text' => $word['text'] ?? '', + 'bounding_box' => array_map(fn ($point) => [ + 'x' => $point[0] ?? 0.0, + 'y' => $point[1] ?? 0.0, + ], $word['boundingBox'] ?? []), + 'confidence' => $word['confidence'] ?? 0.0, + ]; + } + } + } + + return $words; + } + + private function extractLines(array $regions): array + { + $lines = []; + foreach ($regions as $region) { + foreach ($region['lines'] ?? [] as $line) { + $lines[] = [ + 'text' => $line['text'] ?? '', + 'bounding_box' => array_map(fn ($point) => [ + 'x' => $point[0] ?? 0.0, + 'y' => $point[1] ?? 0.0, + ], $line['boundingBox'] ?? []), + ]; + } + } + + return $lines; + } + + private function generateModerationRecommendations(array $responseData): array + { + $recommendations = []; + + if (($responseData['AdultClassificationScore'] ?? 0) > 0.5) { + $recommendations[] = 'Content may contain adult material'; + } + + if (($responseData['RacyClassificationScore'] ?? 0) > 0.5) { + $recommendations[] = 'Content may contain racy material'; + } + + return $recommendations; + } + + private function createSsml(string $text, string $voice, array $options): string + { + $rate = $options['rate'] ?? 'medium'; + $pitch = $options['pitch'] ?? 'medium'; + $volume = $options['volume'] ?? 'medium'; + + return << + + + {$text} + + + +SSML; + } + + private function estimateAudioDuration(string $text): float + { + // Rough estimate: 150 words per minute + $wordCount = str_word_count($text); + + return ($wordCount / 150) * 60; + } +} diff --git a/src/agent/src/Toolbox/Tool/Bearly.php b/src/agent/src/Toolbox/Tool/Bearly.php new file mode 100644 index 000000000..4778a22fd --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Bearly.php @@ -0,0 +1,976 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('bearly_analyze_url', 'Tool that analyzes URLs using Bearly AI')] +#[AsTool('bearly_analyze_text', 'Tool that analyzes text content', method: 'analyzeText')] +#[AsTool('bearly_analyze_image', 'Tool that analyzes images', method: 'analyzeImage')] +#[AsTool('bearly_analyze_document', 'Tool that analyzes documents', method: 'analyzeDocument')] +#[AsTool('bearly_summarize_content', 'Tool that summarizes content', method: 'summarizeContent')] +#[AsTool('bearly_extract_keywords', 'Tool that extracts keywords', method: 'extractKeywords')] +#[AsTool('bearly_get_insights', 'Tool that gets content insights', method: 'getInsights')] +#[AsTool('bearly_translate_content', 'Tool that translates content', method: 'translateContent')] +final readonly class Bearly +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.bearly.ai/v1', + private array $options = [], + ) { + } + + /** + * Analyze URL using Bearly AI. + * + * @param string $url URL to analyze + * @param array $analysisTypes Types of analysis to perform + * @param string $language Language for analysis + * @param bool $includeImages Include image analysis + * @param bool $includeLinks Include link analysis + * + * @return array{ + * success: bool, + * analysis: array{ + * url: string, + * title: string, + * description: string, + * content: string, + * wordCount: int, + * readingTime: int, + * language: string, + * sentiment: array{ + * score: float, + * label: string, + * confidence: float, + * }, + * topics: array, + * keywords: array, + * entities: array, + * summary: string, + * keyPoints: array, + * images: array, + * links: array, + * metadata: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $url, + array $analysisTypes = ['content', 'sentiment', 'topics', 'entities', 'summary'], + string $language = 'en', + bool $includeImages = true, + bool $includeLinks = true, + ): array { + try { + $requestData = [ + 'url' => $url, + 'analysis_types' => $analysisTypes, + 'language' => $language, + 'include_images' => $includeImages, + 'include_links' => $includeLinks, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/analyze/url", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $analysis = $data['analysis'] ?? []; + + return [ + 'success' => true, + 'analysis' => [ + 'url' => $analysis['url'] ?? $url, + 'title' => $analysis['title'] ?? '', + 'description' => $analysis['description'] ?? '', + 'content' => $analysis['content'] ?? '', + 'wordCount' => $analysis['word_count'] ?? 0, + 'readingTime' => $analysis['reading_time'] ?? 0, + 'language' => $analysis['language'] ?? $language, + 'sentiment' => [ + 'score' => $analysis['sentiment']['score'] ?? 0.0, + 'label' => $analysis['sentiment']['label'] ?? 'neutral', + 'confidence' => $analysis['sentiment']['confidence'] ?? 0.0, + ], + 'topics' => $analysis['topics'] ?? [], + 'keywords' => $analysis['keywords'] ?? [], + 'entities' => array_map(fn ($entity) => [ + 'text' => $entity['text'] ?? '', + 'type' => $entity['type'] ?? '', + 'confidence' => $entity['confidence'] ?? 0.0, + ], $analysis['entities'] ?? []), + 'summary' => $analysis['summary'] ?? '', + 'keyPoints' => $analysis['key_points'] ?? [], + 'images' => array_map(fn ($image) => [ + 'url' => $image['url'] ?? '', + 'alt' => $image['alt'] ?? '', + 'caption' => $image['caption'] ?? '', + ], $analysis['images'] ?? []), + 'links' => array_map(fn ($link) => [ + 'url' => $link['url'] ?? '', + 'text' => $link['text'] ?? '', + 'type' => $link['type'] ?? '', + ], $analysis['links'] ?? []), + 'metadata' => $analysis['metadata'] ?? [], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'analysis' => [ + 'url' => $url, + 'title' => '', + 'description' => '', + 'content' => '', + 'wordCount' => 0, + 'readingTime' => 0, + 'language' => $language, + 'sentiment' => ['score' => 0.0, 'label' => 'neutral', 'confidence' => 0.0], + 'topics' => [], + 'keywords' => [], + 'entities' => [], + 'summary' => '', + 'keyPoints' => [], + 'images' => [], + 'links' => [], + 'metadata' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze text content. + * + * @param string $text Text content to analyze + * @param array $analysisTypes Types of analysis to perform + * @param string $language Language for analysis + * + * @return array{ + * success: bool, + * analysis: array{ + * text: string, + * wordCount: int, + * characterCount: int, + * language: string, + * sentiment: array{ + * score: float, + * label: string, + * confidence: float, + * }, + * topics: array, + * keywords: array, + * entities: array, + * summary: string, + * keyPoints: array, + * readability: array{ + * score: float, + * level: string, + * }, + * emotions: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function analyzeText( + string $text, + array $analysisTypes = ['sentiment', 'topics', 'entities', 'summary', 'readability'], + string $language = 'en', + ): array { + try { + $requestData = [ + 'text' => $text, + 'analysis_types' => $analysisTypes, + 'language' => $language, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/analyze/text", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $analysis = $data['analysis'] ?? []; + + return [ + 'success' => true, + 'analysis' => [ + 'text' => $text, + 'wordCount' => $analysis['word_count'] ?? str_word_count($text), + 'characterCount' => $analysis['character_count'] ?? \strlen($text), + 'language' => $analysis['language'] ?? $language, + 'sentiment' => [ + 'score' => $analysis['sentiment']['score'] ?? 0.0, + 'label' => $analysis['sentiment']['label'] ?? 'neutral', + 'confidence' => $analysis['sentiment']['confidence'] ?? 0.0, + ], + 'topics' => $analysis['topics'] ?? [], + 'keywords' => $analysis['keywords'] ?? [], + 'entities' => array_map(fn ($entity) => [ + 'text' => $entity['text'] ?? '', + 'type' => $entity['type'] ?? '', + 'confidence' => $entity['confidence'] ?? 0.0, + ], $analysis['entities'] ?? []), + 'summary' => $analysis['summary'] ?? '', + 'keyPoints' => $analysis['key_points'] ?? [], + 'readability' => [ + 'score' => $analysis['readability']['score'] ?? 0.0, + 'level' => $analysis['readability']['level'] ?? 'intermediate', + ], + 'emotions' => $analysis['emotions'] ?? [], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'analysis' => [ + 'text' => $text, + 'wordCount' => str_word_count($text), + 'characterCount' => \strlen($text), + 'language' => $language, + 'sentiment' => ['score' => 0.0, 'label' => 'neutral', 'confidence' => 0.0], + 'topics' => [], + 'keywords' => [], + 'entities' => [], + 'summary' => '', + 'keyPoints' => [], + 'readability' => ['score' => 0.0, 'level' => 'intermediate'], + 'emotions' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze images. + * + * @param string $imageUrl URL of image to analyze + * @param array $analysisTypes Types of analysis to perform + * @param string $language Language for analysis + * + * @return array{ + * success: bool, + * analysis: array{ + * imageUrl: string, + * width: int, + * height: int, + * format: string, + * fileSize: int, + * objects: array, + * text: array, + * faces: array, + * age: int, + * gender: string, + * boundingBox: array{ + * x: float, + * y: float, + * width: float, + * height: float, + * }, + * }>, + * colors: array, + * labels: array, + * description: string, + * tags: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function analyzeImage( + string $imageUrl, + array $analysisTypes = ['objects', 'text', 'faces', 'colors', 'labels', 'description'], + string $language = 'en', + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'analysis_types' => $analysisTypes, + 'language' => $language, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/analyze/image", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $analysis = $data['analysis'] ?? []; + + return [ + 'success' => true, + 'analysis' => [ + 'imageUrl' => $imageUrl, + 'width' => $analysis['width'] ?? 0, + 'height' => $analysis['height'] ?? 0, + 'format' => $analysis['format'] ?? '', + 'fileSize' => $analysis['file_size'] ?? 0, + 'objects' => array_map(fn ($object) => [ + 'name' => $object['name'] ?? '', + 'confidence' => $object['confidence'] ?? 0.0, + 'boundingBox' => [ + 'x' => $object['bounding_box']['x'] ?? 0.0, + 'y' => $object['bounding_box']['y'] ?? 0.0, + 'width' => $object['bounding_box']['width'] ?? 0.0, + 'height' => $object['bounding_box']['height'] ?? 0.0, + ], + ], $analysis['objects'] ?? []), + 'text' => array_map(fn ($text) => [ + 'text' => $text['text'] ?? '', + 'confidence' => $text['confidence'] ?? 0.0, + 'boundingBox' => [ + 'x' => $text['bounding_box']['x'] ?? 0.0, + 'y' => $text['bounding_box']['y'] ?? 0.0, + 'width' => $text['bounding_box']['width'] ?? 0.0, + 'height' => $text['bounding_box']['height'] ?? 0.0, + ], + ], $analysis['text'] ?? []), + 'faces' => array_map(fn ($face) => [ + 'confidence' => $face['confidence'] ?? 0.0, + 'emotions' => $face['emotions'] ?? [], + 'age' => $face['age'] ?? 0, + 'gender' => $face['gender'] ?? '', + 'boundingBox' => [ + 'x' => $face['bounding_box']['x'] ?? 0.0, + 'y' => $face['bounding_box']['y'] ?? 0.0, + 'width' => $face['bounding_box']['width'] ?? 0.0, + 'height' => $face['bounding_box']['height'] ?? 0.0, + ], + ], $analysis['faces'] ?? []), + 'colors' => array_map(fn ($color) => [ + 'color' => $color['color'] ?? '', + 'hex' => $color['hex'] ?? '', + 'percentage' => $color['percentage'] ?? 0.0, + ], $analysis['colors'] ?? []), + 'labels' => array_map(fn ($label) => [ + 'name' => $label['name'] ?? '', + 'confidence' => $label['confidence'] ?? 0.0, + ], $analysis['labels'] ?? []), + 'description' => $analysis['description'] ?? '', + 'tags' => $analysis['tags'] ?? [], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'analysis' => [ + 'imageUrl' => $imageUrl, + 'width' => 0, + 'height' => 0, + 'format' => '', + 'fileSize' => 0, + 'objects' => [], + 'text' => [], + 'faces' => [], + 'colors' => [], + 'labels' => [], + 'description' => '', + 'tags' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze documents. + * + * @param string $documentUrl URL of document to analyze + * @param string $documentType Document type (pdf, docx, txt, etc.) + * @param array $analysisTypes Types of analysis to perform + * @param string $language Language for analysis + * + * @return array{ + * success: bool, + * analysis: array{ + * documentUrl: string, + * documentType: string, + * title: string, + * content: string, + * wordCount: int, + * pageCount: int, + * language: string, + * sentiment: array{ + * score: float, + * label: string, + * confidence: float, + * }, + * topics: array, + * keywords: array, + * entities: array, + * summary: string, + * keyPoints: array, + * structure: array{ + * headings: array, + * paragraphs: int, + * tables: int, + * images: int, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function analyzeDocument( + string $documentUrl, + string $documentType = 'pdf', + array $analysisTypes = ['content', 'sentiment', 'topics', 'entities', 'summary', 'structure'], + string $language = 'en', + ): array { + try { + $requestData = [ + 'document_url' => $documentUrl, + 'document_type' => $documentType, + 'analysis_types' => $analysisTypes, + 'language' => $language, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/analyze/document", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $analysis = $data['analysis'] ?? []; + + return [ + 'success' => true, + 'analysis' => [ + 'documentUrl' => $documentUrl, + 'documentType' => $documentType, + 'title' => $analysis['title'] ?? '', + 'content' => $analysis['content'] ?? '', + 'wordCount' => $analysis['word_count'] ?? 0, + 'pageCount' => $analysis['page_count'] ?? 0, + 'language' => $analysis['language'] ?? $language, + 'sentiment' => [ + 'score' => $analysis['sentiment']['score'] ?? 0.0, + 'label' => $analysis['sentiment']['label'] ?? 'neutral', + 'confidence' => $analysis['sentiment']['confidence'] ?? 0.0, + ], + 'topics' => $analysis['topics'] ?? [], + 'keywords' => $analysis['keywords'] ?? [], + 'entities' => array_map(fn ($entity) => [ + 'text' => $entity['text'] ?? '', + 'type' => $entity['type'] ?? '', + 'confidence' => $entity['confidence'] ?? 0.0, + ], $analysis['entities'] ?? []), + 'summary' => $analysis['summary'] ?? '', + 'keyPoints' => $analysis['key_points'] ?? [], + 'structure' => [ + 'headings' => array_map(fn ($heading) => [ + 'level' => $heading['level'] ?? 1, + 'text' => $heading['text'] ?? '', + 'page' => $heading['page'] ?? 1, + ], $analysis['structure']['headings'] ?? []), + 'paragraphs' => $analysis['structure']['paragraphs'] ?? 0, + 'tables' => $analysis['structure']['tables'] ?? 0, + 'images' => $analysis['structure']['images'] ?? 0, + ], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'analysis' => [ + 'documentUrl' => $documentUrl, + 'documentType' => $documentType, + 'title' => '', + 'content' => '', + 'wordCount' => 0, + 'pageCount' => 0, + 'language' => $language, + 'sentiment' => ['score' => 0.0, 'label' => 'neutral', 'confidence' => 0.0], + 'topics' => [], + 'keywords' => [], + 'entities' => [], + 'summary' => '', + 'keyPoints' => [], + 'structure' => [ + 'headings' => [], + 'paragraphs' => 0, + 'tables' => 0, + 'images' => 0, + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Summarize content. + * + * @param string $content Content to summarize + * @param string $contentType Type of content (url, text, document) + * @param int $maxLength Maximum summary length + * @param string $style Summary style (brief, detailed, bulleted) + * @param string $language Language for summary + * + * @return array{ + * success: bool, + * summary: array{ + * text: string, + * length: int, + * style: string, + * keyPoints: array, + * keywords: array, + * topics: array, + * confidence: float, + * originalLength: int, + * compressionRatio: float, + * }, + * contentType: string, + * processingTime: float, + * error: string, + * } + */ + public function summarizeContent( + string $content, + string $contentType = 'text', + int $maxLength = 200, + string $style = 'brief', + string $language = 'en', + ): array { + try { + $requestData = [ + 'content' => $content, + 'content_type' => $contentType, + 'max_length' => max(50, min($maxLength, 1000)), + 'style' => $style, + 'language' => $language, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/summarize", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $summary = $data['summary'] ?? []; + $originalLength = \strlen($content); + + return [ + 'success' => true, + 'summary' => [ + 'text' => $summary['text'] ?? '', + 'length' => $summary['length'] ?? 0, + 'style' => $summary['style'] ?? $style, + 'keyPoints' => $summary['key_points'] ?? [], + 'keywords' => $summary['keywords'] ?? [], + 'topics' => $summary['topics'] ?? [], + 'confidence' => $summary['confidence'] ?? 0.0, + 'originalLength' => $originalLength, + 'compressionRatio' => $originalLength > 0 ? ($summary['length'] ?? 0) / $originalLength : 0.0, + ], + 'contentType' => $contentType, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'summary' => [ + 'text' => '', + 'length' => 0, + 'style' => $style, + 'keyPoints' => [], + 'keywords' => [], + 'topics' => [], + 'confidence' => 0.0, + 'originalLength' => \strlen($content), + 'compressionRatio' => 0.0, + ], + 'contentType' => $contentType, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Extract keywords. + * + * @param string $content Content to extract keywords from + * @param string $contentType Type of content (url, text, document) + * @param int $maxKeywords Maximum number of keywords + * @param string $language Language for extraction + * + * @return array{ + * success: bool, + * keywords: array, + * totalKeywords: int, + * contentType: string, + * processingTime: float, + * error: string, + * } + */ + public function extractKeywords( + string $content, + string $contentType = 'text', + int $maxKeywords = 20, + string $language = 'en', + ): array { + try { + $requestData = [ + 'content' => $content, + 'content_type' => $contentType, + 'max_keywords' => max(1, min($maxKeywords, 100)), + 'language' => $language, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/extract/keywords", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $keywords = $data['keywords'] ?? []; + + return [ + 'success' => true, + 'keywords' => array_map(fn ($keyword) => [ + 'keyword' => $keyword['keyword'] ?? '', + 'relevance' => $keyword['relevance'] ?? 0.0, + 'frequency' => $keyword['frequency'] ?? 0, + 'category' => $keyword['category'] ?? '', + ], $keywords), + 'totalKeywords' => \count($keywords), + 'contentType' => $contentType, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'keywords' => [], + 'totalKeywords' => 0, + 'contentType' => $contentType, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get content insights. + * + * @param string $content Content to analyze + * @param string $contentType Type of content (url, text, document) + * @param string $language Language for analysis + * + * @return array{ + * success: bool, + * insights: array{ + * readability: array{ + * score: float, + * level: string, + * gradeLevel: int, + * }, + * complexity: array{ + * score: float, + * level: string, + * factors: array, + * }, + * tone: array{ + * score: float, + * label: string, + * confidence: float, + * }, + * intent: array{ + * primary: string, + * secondary: array, + * confidence: float, + * }, + * audience: array{ + * level: string, + * demographics: array, + * interests: array, + * }, + * engagement: array{ + * score: float, + * factors: array, + * recommendations: array, + * }, + * }, + * contentType: string, + * processingTime: float, + * error: string, + * } + */ + public function getInsights( + string $content, + string $contentType = 'text', + string $language = 'en', + ): array { + try { + $requestData = [ + 'content' => $content, + 'content_type' => $contentType, + 'language' => $language, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/insights", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $insights = $data['insights'] ?? []; + + return [ + 'success' => true, + 'insights' => [ + 'readability' => [ + 'score' => $insights['readability']['score'] ?? 0.0, + 'level' => $insights['readability']['level'] ?? 'intermediate', + 'gradeLevel' => $insights['readability']['grade_level'] ?? 8, + ], + 'complexity' => [ + 'score' => $insights['complexity']['score'] ?? 0.0, + 'level' => $insights['complexity']['level'] ?? 'medium', + 'factors' => $insights['complexity']['factors'] ?? [], + ], + 'tone' => [ + 'score' => $insights['tone']['score'] ?? 0.0, + 'label' => $insights['tone']['label'] ?? 'neutral', + 'confidence' => $insights['tone']['confidence'] ?? 0.0, + ], + 'intent' => [ + 'primary' => $insights['intent']['primary'] ?? '', + 'secondary' => $insights['intent']['secondary'] ?? [], + 'confidence' => $insights['intent']['confidence'] ?? 0.0, + ], + 'audience' => [ + 'level' => $insights['audience']['level'] ?? 'general', + 'demographics' => $insights['audience']['demographics'] ?? [], + 'interests' => $insights['audience']['interests'] ?? [], + ], + 'engagement' => [ + 'score' => $insights['engagement']['score'] ?? 0.0, + 'factors' => $insights['engagement']['factors'] ?? [], + 'recommendations' => $insights['engagement']['recommendations'] ?? [], + ], + ], + 'contentType' => $contentType, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'insights' => [ + 'readability' => ['score' => 0.0, 'level' => 'intermediate', 'gradeLevel' => 8], + 'complexity' => ['score' => 0.0, 'level' => 'medium', 'factors' => []], + 'tone' => ['score' => 0.0, 'label' => 'neutral', 'confidence' => 0.0], + 'intent' => ['primary' => '', 'secondary' => [], 'confidence' => 0.0], + 'audience' => ['level' => 'general', 'demographics' => [], 'interests' => []], + 'engagement' => ['score' => 0.0, 'factors' => [], 'recommendations' => []], + ], + 'contentType' => $contentType, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Translate content. + * + * @param string $content Content to translate + * @param string $targetLanguage Target language code + * @param string $sourceLanguage Source language code (auto-detect if empty) + * @param string $contentType Type of content (text, url, document) + * + * @return array{ + * success: bool, + * translation: array{ + * originalText: string, + * translatedText: string, + * sourceLanguage: string, + * targetLanguage: string, + * confidence: float, + * wordCount: int, + * characterCount: int, + * }, + * contentType: string, + * processingTime: float, + * error: string, + * } + */ + public function translateContent( + string $content, + string $targetLanguage, + string $sourceLanguage = '', + string $contentType = 'text', + ): array { + try { + $requestData = [ + 'content' => $content, + 'target_language' => $targetLanguage, + 'content_type' => $contentType, + ]; + + if ($sourceLanguage) { + $requestData['source_language'] = $sourceLanguage; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/translate", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $translation = $data['translation'] ?? []; + + return [ + 'success' => true, + 'translation' => [ + 'originalText' => $content, + 'translatedText' => $translation['translated_text'] ?? '', + 'sourceLanguage' => $translation['source_language'] ?? $sourceLanguage, + 'targetLanguage' => $targetLanguage, + 'confidence' => $translation['confidence'] ?? 0.0, + 'wordCount' => $translation['word_count'] ?? 0, + 'characterCount' => $translation['character_count'] ?? 0, + ], + 'contentType' => $contentType, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'translation' => [ + 'originalText' => $content, + 'translatedText' => '', + 'sourceLanguage' => $sourceLanguage, + 'targetLanguage' => $targetLanguage, + 'confidence' => 0.0, + 'wordCount' => 0, + 'characterCount' => 0, + ], + 'contentType' => $contentType, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/BingSearch.php b/src/agent/src/Toolbox/Tool/BingSearch.php new file mode 100644 index 000000000..b88ce9680 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/BingSearch.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('bing_search', 'Tool that searches the web using Bing Search API')] +#[AsTool('bing_search_results_json', 'Tool that searches Bing and returns structured JSON results', method: 'searchJson')] +final readonly class BingSearch +{ + /** + * @param array $options Additional search options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private array $options = [], + ) { + } + + /** + * @param string $query the search query term + * @param int $count The number of search results returned in response. + * Combine this parameter with offset to paginate search results. + * @param int $offset The number of search results to skip before returning results. + * In order to paginate results use this parameter together with count. + * + * @return array + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + int $count = 10, + #[With(minimum: 0, maximum: 9)] + int $offset = 0, + ): array { + try { + $response = $this->httpClient->request('GET', 'https://api.bing.microsoft.com/v7.0/search', [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->apiKey, + ], + 'query' => array_merge($this->options, [ + 'q' => $query, + 'count' => $count, + 'offset' => $offset, + 'mkt' => 'en-US', + 'safesearch' => 'Moderate', + ]), + ]); + + $data = $response->toArray(); + + $results = []; + foreach ($data['webPages']['value'] ?? [] as $result) { + $results[] = [ + 'title' => $result['name'] ?? '', + 'snippet' => $result['snippet'] ?? '', + 'url' => $result['url'] ?? '', + ]; + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'title' => 'Search Error', + 'snippet' => 'Unable to perform search: '.$e->getMessage(), + 'url' => '', + ], + ]; + } + } + + /** + * @param string $query the search query term + * @param int $numResults The number of search results to return + * + * @return array + */ + public function searchJson( + #[With(maximum: 500)] + string $query, + int $numResults = 4, + ): array { + return $this->__invoke($query, $numResults); + } +} diff --git a/src/agent/src/Toolbox/Tool/Bitbucket.php b/src/agent/src/Toolbox/Tool/Bitbucket.php new file mode 100644 index 000000000..b3a4498bf --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Bitbucket.php @@ -0,0 +1,533 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('bitbucket_get_repositories', 'Tool that gets Bitbucket repositories')] +#[AsTool('bitbucket_create_repository', 'Tool that creates Bitbucket repositories', method: 'createRepository')] +#[AsTool('bitbucket_get_issues', 'Tool that gets Bitbucket issues', method: 'getIssues')] +#[AsTool('bitbucket_create_issue', 'Tool that creates Bitbucket issues', method: 'createIssue')] +#[AsTool('bitbucket_get_pull_requests', 'Tool that gets Bitbucket pull requests', method: 'getPullRequests')] +#[AsTool('bitbucket_create_pull_request', 'Tool that creates Bitbucket pull requests', method: 'createPullRequest')] +final readonly class Bitbucket +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $username, + #[\SensitiveParameter] private string $appPassword, + private string $apiVersion = '2.0', + private array $options = [], + ) { + } + + /** + * Get Bitbucket repositories. + * + * @param string $workspace Bitbucket workspace/team name + * @param int $limit Number of repositories to retrieve + * @param string $sort Sort by field (name, created_on, updated_on, size) + * + * @return array, + * }, + * }> + */ + public function __invoke( + string $workspace, + int $limit = 20, + string $sort = 'updated_on', + ): array { + try { + $params = [ + 'pagelen' => min(max($limit, 1), 100), + 'sort' => '-'.$sort, + ]; + + $response = $this->httpClient->request('GET', "https://api.bitbucket.org/{$this->apiVersion}/repositories/{$workspace}", [ + 'auth_basic' => [$this->username, $this->appPassword], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['values'])) { + return []; + } + + $repositories = []; + foreach ($data['values'] as $repo) { + $repositories[] = [ + 'uuid' => $repo['uuid'], + 'name' => $repo['name'], + 'slug' => $repo['slug'], + 'full_name' => $repo['full_name'], + 'description' => $repo['description'] ?? '', + 'is_private' => $repo['is_private'], + 'created_on' => $repo['created_on'], + 'updated_on' => $repo['updated_on'], + 'size' => $repo['size'], + 'language' => $repo['language'] ?? '', + 'has_issues' => $repo['has_issues'], + 'has_wiki' => $repo['has_wiki'], + 'fork_policy' => $repo['fork_policy'], + 'project' => $repo['project'] ?? null, + 'mainbranch' => $repo['mainbranch'], + 'links' => [ + 'self' => $repo['links']['self'], + 'html' => $repo['links']['html'], + 'clone' => $repo['links']['clone'], + ], + ]; + } + + return $repositories; + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a Bitbucket repository. + * + * @param string $workspace Bitbucket workspace/team name + * @param string $name Repository name + * @param string $description Repository description + * @param bool $isPrivate Whether repository is private + * @param string $language Primary programming language + * + * @return array{ + * uuid: string, + * name: string, + * slug: string, + * full_name: string, + * description: string, + * is_private: bool, + * created_on: string, + * updated_on: string, + * language: string, + * has_issues: bool, + * has_wiki: bool, + * fork_policy: string, + * mainbranch: array{name: string, type: string}, + * links: array{ + * self: array{href: string}, + * html: array{href: string}, + * clone: array, + * }, + * }|string + */ + public function createRepository( + string $workspace, + string $name, + string $description = '', + bool $isPrivate = true, + string $language = '', + ): array|string { + try { + $payload = [ + 'name' => $name, + 'description' => $description, + 'is_private' => $isPrivate, + 'has_issues' => true, + 'has_wiki' => true, + 'fork_policy' => 'allow_forks', + ]; + + if ($language) { + $payload['language'] = $language; + } + + $response = $this->httpClient->request('POST', "https://api.bitbucket.org/{$this->apiVersion}/repositories/{$workspace}/{$name}", [ + 'auth_basic' => [$this->username, $this->appPassword], + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating repository: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'uuid' => $data['uuid'], + 'name' => $data['name'], + 'slug' => $data['slug'], + 'full_name' => $data['full_name'], + 'description' => $data['description'] ?? '', + 'is_private' => $data['is_private'], + 'created_on' => $data['created_on'], + 'updated_on' => $data['updated_on'], + 'language' => $data['language'] ?? '', + 'has_issues' => $data['has_issues'], + 'has_wiki' => $data['has_wiki'], + 'fork_policy' => $data['fork_policy'], + 'mainbranch' => $data['mainbranch'], + 'links' => [ + 'self' => $data['links']['self'], + 'html' => $data['links']['html'], + 'clone' => $data['links']['clone'], + ], + ]; + } catch (\Exception $e) { + return 'Error creating repository: '.$e->getMessage(); + } + } + + /** + * Get Bitbucket issues. + * + * @param string $workspace Bitbucket workspace/team name + * @param string $repository Repository name + * @param int $limit Number of issues to retrieve + * @param string $state Issue state (new, open, resolved, on hold, invalid, duplicate, wontfix, closed) + * @param string $priority Issue priority (trivial, minor, major, critical, blocker) + * + * @return array + */ + public function getIssues( + string $workspace, + string $repository, + int $limit = 20, + string $state = '', + string $priority = '', + ): array { + try { + $params = [ + 'pagelen' => min(max($limit, 1), 100), + ]; + + if ($state) { + $params['q'] = 'state="'.$state.'"'; + } + if ($priority) { + $q = $params['q'] ?? ''; + $params['q'] = $q ? $q.' AND priority="'.$priority.'"' : 'priority="'.$priority.'"'; + } + + $response = $this->httpClient->request('GET', "https://api.bitbucket.org/{$this->apiVersion}/repositories/{$workspace}/{$repository}/issues", [ + 'auth_basic' => [$this->username, $this->appPassword], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['values'])) { + return []; + } + + $issues = []; + foreach ($data['values'] as $issue) { + $issues[] = [ + 'id' => $issue['id'], + 'title' => $issue['title'], + 'content' => $issue['content'], + 'state' => $issue['state'], + 'kind' => $issue['kind'], + 'priority' => $issue['priority'], + 'votes' => $issue['votes'], + 'reporter' => $issue['reporter'], + 'assignee' => $issue['assignee'] ?? null, + 'created_on' => $issue['created_on'], + 'updated_on' => $issue['updated_on'], + 'links' => $issue['links'], + ]; + } + + return $issues; + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a Bitbucket issue. + * + * @param string $workspace Bitbucket workspace/team name + * @param string $repository Repository name + * @param string $title Issue title + * @param string $content Issue content/description + * @param string $kind Issue kind (bug, enhancement, proposal, task) + * @param string $priority Issue priority (trivial, minor, major, critical, blocker) + * @param string $assignee Assignee username + * + * @return array{ + * id: int, + * title: string, + * content: array{raw: string, markup: string, html: string, type: string}, + * state: string, + * kind: string, + * priority: string, + * votes: int, + * reporter: array{display_name: string, uuid: string, links: array{self: array{href: string}, html: array{href: string}}}, + * assignee: array{display_name: string, uuid: string, links: array{self: array{href: string}, html: array{href: string}}}|null, + * created_on: string, + * updated_on: string, + * links: array{self: array{href: string}, html: array{href: string}}, + * }|string + */ + public function createIssue( + string $workspace, + string $repository, + string $title, + string $content, + string $kind = 'bug', + string $priority = 'major', + string $assignee = '', + ): array|string { + try { + $payload = [ + 'title' => $title, + 'content' => [ + 'raw' => $content, + 'markup' => 'markdown', + ], + 'kind' => $kind, + 'priority' => $priority, + ]; + + if ($assignee) { + $payload['assignee'] = [ + 'username' => $assignee, + ]; + } + + $response = $this->httpClient->request('POST', "https://api.bitbucket.org/{$this->apiVersion}/repositories/{$workspace}/{$repository}/issues", [ + 'auth_basic' => [$this->username, $this->appPassword], + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating issue: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'title' => $data['title'], + 'content' => $data['content'], + 'state' => $data['state'], + 'kind' => $data['kind'], + 'priority' => $data['priority'], + 'votes' => $data['votes'], + 'reporter' => $data['reporter'], + 'assignee' => $data['assignee'] ?? null, + 'created_on' => $data['created_on'], + 'updated_on' => $data['updated_on'], + 'links' => $data['links'], + ]; + } catch (\Exception $e) { + return 'Error creating issue: '.$e->getMessage(); + } + } + + /** + * Get Bitbucket pull requests. + * + * @param string $workspace Bitbucket workspace/team name + * @param string $repository Repository name + * @param int $limit Number of pull requests to retrieve + * @param string $state Pull request state (OPEN, MERGED, DECLINED, SUPERSEDED) + * + * @return array, + * created_on: string, + * updated_on: string, + * links: array{self: array{href: string}, html: array{href: string}}, + * }> + */ + public function getPullRequests( + string $workspace, + string $repository, + int $limit = 20, + string $state = 'OPEN', + ): array { + try { + $params = [ + 'pagelen' => min(max($limit, 1), 100), + 'state' => $state, + ]; + + $response = $this->httpClient->request('GET', "https://api.bitbucket.org/{$this->apiVersion}/repositories/{$workspace}/{$repository}/pullrequests", [ + 'auth_basic' => [$this->username, $this->appPassword], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['values'])) { + return []; + } + + $pullRequests = []; + foreach ($data['values'] as $pr) { + $pullRequests[] = [ + 'id' => $pr['id'], + 'title' => $pr['title'], + 'description' => $pr['description'] ?? '', + 'state' => $pr['state'], + 'author' => $pr['author'], + 'source' => $pr['source'], + 'destination' => $pr['destination'], + 'reviewers' => $pr['reviewers'] ?? [], + 'created_on' => $pr['created_on'], + 'updated_on' => $pr['updated_on'], + 'links' => $pr['links'], + ]; + } + + return $pullRequests; + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a Bitbucket pull request. + * + * @param string $workspace Bitbucket workspace/team name + * @param string $repository Repository name + * @param string $title Pull request title + * @param string $description Pull request description + * @param string $sourceBranch Source branch name + * @param string $destinationBranch Destination branch name + * @param array $reviewers Reviewer usernames + * + * @return array{ + * id: int, + * title: string, + * description: string, + * state: string, + * author: array{display_name: string, uuid: string, links: array{self: array{href: string}, html: array{href: string}}}, + * source: array{branch: array{name: string}, commit: array{hash: string}}, + * destination: array{branch: array{name: string}, commit: array{hash: string}}, + * reviewers: array, + * created_on: string, + * updated_on: string, + * links: array{self: array{href: string}, html: array{href: string}}, + * }|string + */ + public function createPullRequest( + string $workspace, + string $repository, + string $title, + string $description, + string $sourceBranch, + string $destinationBranch, + array $reviewers = [], + ): array|string { + try { + $payload = [ + 'title' => $title, + 'description' => $description, + 'source' => [ + 'branch' => [ + 'name' => $sourceBranch, + ], + ], + 'destination' => [ + 'branch' => [ + 'name' => $destinationBranch, + ], + ], + ]; + + if (!empty($reviewers)) { + $payload['reviewers'] = array_map(fn ($username) => [ + 'username' => $username, + ], $reviewers); + } + + $response = $this->httpClient->request('POST', "https://api.bitbucket.org/{$this->apiVersion}/repositories/{$workspace}/{$repository}/pullrequests", [ + 'auth_basic' => [$this->username, $this->appPassword], + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating pull request: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'title' => $data['title'], + 'description' => $data['description'] ?? '', + 'state' => $data['state'], + 'author' => $data['author'], + 'source' => $data['source'], + 'destination' => $data['destination'], + 'reviewers' => $data['reviewers'] ?? [], + 'created_on' => $data['created_on'], + 'updated_on' => $data['updated_on'], + 'links' => $data['links'], + ]; + } catch (\Exception $e) { + return 'Error creating pull request: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/CassandraDatabase.php b/src/agent/src/Toolbox/Tool/CassandraDatabase.php new file mode 100644 index 000000000..0388d1580 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/CassandraDatabase.php @@ -0,0 +1,599 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('cassandra_execute_cql', 'Tool that executes CQL queries on Cassandra')] +#[AsTool('cassandra_create_keyspace', 'Tool that creates Cassandra keyspace', method: 'createKeyspace')] +#[AsTool('cassandra_create_table', 'Tool that creates Cassandra table', method: 'createTable')] +#[AsTool('cassandra_insert_data', 'Tool that inserts data into Cassandra', method: 'insertData')] +#[AsTool('cassandra_select_data', 'Tool that selects data from Cassandra', method: 'selectData')] +#[AsTool('cassandra_update_data', 'Tool that updates data in Cassandra', method: 'updateData')] +#[AsTool('cassandra_delete_data', 'Tool that deletes data from Cassandra', method: 'deleteData')] +#[AsTool('cassandra_describe_keyspaces', 'Tool that describes Cassandra keyspaces', method: 'describeKeyspaces')] +#[AsTool('cassandra_describe_tables', 'Tool that describes Cassandra tables', method: 'describeTables')] +final readonly class CassandraDatabase +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $host = 'localhost', + private int $port = 9042, + private string $username = '', + #[\SensitiveParameter] private string $password = '', + private string $keyspace = '', + private array $options = [], + ) { + } + + /** + * Execute CQL query on Cassandra. + * + * @param string $cql CQL query to execute + * @param array $params Query parameters + * @param string $consistency Consistency level + * + * @return array{ + * success: bool, + * results: array>, + * columns: array, + * executionTime: float, + * error: string, + * } + */ + public function __invoke( + string $cql, + array $params = [], + string $consistency = 'ONE', + ): array { + try { + $startTime = microtime(true); + + // This is a simplified implementation + // In reality, you would use a proper Cassandra driver like DataStax PHP driver + $command = $this->buildCqlshCommand($cql, $params); + $output = $this->executeCommand($command); + + $executionTime = microtime(true) - $startTime; + + // Parse output (simplified) + $results = $this->parseCqlOutput($output); + + return [ + 'success' => true, + 'results' => $results['data'], + 'columns' => $results['columns'], + 'executionTime' => $executionTime, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'results' => [], + 'columns' => [], + 'executionTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create Cassandra keyspace. + * + * @param string $keyspaceName Keyspace name + * @param string $replicationStrategy Replication strategy (SimpleStrategy, NetworkTopologyStrategy) + * @param int $replicationFactor Replication factor + * + * @return array{ + * success: bool, + * keyspace: string, + * message: string, + * error: string, + * } + */ + public function createKeyspace( + string $keyspaceName, + string $replicationStrategy = 'SimpleStrategy', + int $replicationFactor = 3, + ): array { + try { + $cql = "CREATE KEYSPACE IF NOT EXISTS {$keyspaceName} WITH REPLICATION = {'class': '{$replicationStrategy}'"; + + if ('SimpleStrategy' === $replicationStrategy) { + $cql .= ", 'replication_factor': {$replicationFactor}"; + } + + $cql .= '};'; + + $result = $this->__invoke($cql); + + return [ + 'success' => $result['success'], + 'keyspace' => $keyspaceName, + 'message' => $result['success'] ? "Keyspace '{$keyspaceName}' created successfully" : 'Failed to create keyspace', + 'error' => $result['error'], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'keyspace' => $keyspaceName, + 'message' => 'Error creating keyspace', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create Cassandra table. + * + * @param string $tableName Table name + * @param array $columns Table columns + * @param string $keyspace Keyspace name + * + * @return array{ + * success: bool, + * table: string, + * keyspace: string, + * message: string, + * error: string, + * } + */ + public function createTable( + string $tableName, + array $columns, + string $keyspace = '', + ): array { + try { + $keyspace = $keyspace ?: $this->keyspace; + if (!$keyspace) { + throw new \InvalidArgumentException('Keyspace is required.'); + } + + $columnDefs = []; + $primaryKey = []; + + foreach ($columns as $column) { + $columnDef = "{$column['name']} {$column['type']}"; + $columnDefs[] = $columnDef; + + if ($column['isPrimaryKey']) { + $primaryKey[] = $column['name']; + } + } + + $cql = "CREATE TABLE IF NOT EXISTS {$keyspace}.{$tableName} (". + implode(', ', $columnDefs). + ', PRIMARY KEY ('.implode(', ', $primaryKey).'));'; + + $result = $this->__invoke($cql); + + return [ + 'success' => $result['success'], + 'table' => $tableName, + 'keyspace' => $keyspace, + 'message' => $result['success'] ? "Table '{$tableName}' created successfully" : 'Failed to create table', + 'error' => $result['error'], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'table' => $tableName, + 'keyspace' => $keyspace, + 'message' => 'Error creating table', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Insert data into Cassandra. + * + * @param string $tableName Table name + * @param array $data Data to insert + * @param string $keyspace Keyspace name + * @param string $ttl Time to live (optional) + * + * @return array{ + * success: bool, + * table: string, + * keyspace: string, + * message: string, + * error: string, + * } + */ + public function insertData( + string $tableName, + array $data, + string $keyspace = '', + string $ttl = '', + ): array { + try { + $keyspace = $keyspace ?: $this->keyspace; + if (!$keyspace) { + throw new \InvalidArgumentException('Keyspace is required.'); + } + + $columns = array_keys($data); + $values = array_values($data); + + $cql = "INSERT INTO {$keyspace}.{$tableName} (".implode(', ', $columns).') VALUES ('. + implode(', ', array_fill(0, \count($values), '?')).')'; + + if ($ttl) { + $cql .= " USING TTL {$ttl}"; + } + + $result = $this->__invoke($cql, $values); + + return [ + 'success' => $result['success'], + 'table' => $tableName, + 'keyspace' => $keyspace, + 'message' => $result['success'] ? 'Data inserted successfully' : 'Failed to insert data', + 'error' => $result['error'], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'table' => $tableName, + 'keyspace' => $keyspace, + 'message' => 'Error inserting data', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Select data from Cassandra. + * + * @param string $tableName Table name + * @param array $where Where conditions + * @param string $keyspace Keyspace name + * @param int $limit Limit results + * + * @return array{ + * success: bool, + * results: array>, + * columns: array, + * count: int, + * error: string, + * } + */ + public function selectData( + string $tableName, + array $where = [], + string $keyspace = '', + int $limit = 100, + ): array { + try { + $keyspace = $keyspace ?: $this->keyspace; + if (!$keyspace) { + throw new \InvalidArgumentException('Keyspace is required.'); + } + + $cql = "SELECT * FROM {$keyspace}.{$tableName}"; + + if (!empty($where)) { + $conditions = []; + foreach ($where as $column => $value) { + $conditions[] = "{$column} = ?"; + } + $cql .= ' WHERE '.implode(' AND ', $conditions); + } + + if ($limit > 0) { + $cql .= " LIMIT {$limit}"; + } + + $result = $this->__invoke($cql, array_values($where)); + + return [ + 'success' => $result['success'], + 'results' => $result['results'], + 'columns' => $result['columns'], + 'count' => \count($result['results']), + 'error' => $result['error'], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'results' => [], + 'columns' => [], + 'count' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Update data in Cassandra. + * + * @param string $tableName Table name + * @param array $data Data to update + * @param array $where Where conditions + * @param string $keyspace Keyspace name + * + * @return array{ + * success: bool, + * table: string, + * keyspace: string, + * message: string, + * error: string, + * } + */ + public function updateData( + string $tableName, + array $data, + array $where, + string $keyspace = '', + ): array { + try { + $keyspace = $keyspace ?: $this->keyspace; + if (!$keyspace) { + throw new \InvalidArgumentException('Keyspace is required.'); + } + + $setClause = []; + foreach (array_keys($data) as $column) { + $setClause[] = "{$column} = ?"; + } + + $whereClause = []; + foreach (array_keys($where) as $column) { + $whereClause[] = "{$column} = ?"; + } + + $cql = "UPDATE {$keyspace}.{$tableName} SET ".implode(', ', $setClause). + ' WHERE '.implode(' AND ', $whereClause); + + $params = array_merge(array_values($data), array_values($where)); + $result = $this->__invoke($cql, $params); + + return [ + 'success' => $result['success'], + 'table' => $tableName, + 'keyspace' => $keyspace, + 'message' => $result['success'] ? 'Data updated successfully' : 'Failed to update data', + 'error' => $result['error'], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'table' => $tableName, + 'keyspace' => $keyspace, + 'message' => 'Error updating data', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Delete data from Cassandra. + * + * @param string $tableName Table name + * @param array $where Where conditions + * @param string $keyspace Keyspace name + * + * @return array{ + * success: bool, + * table: string, + * keyspace: string, + * message: string, + * error: string, + * } + */ + public function deleteData( + string $tableName, + array $where, + string $keyspace = '', + ): array { + try { + $keyspace = $keyspace ?: $this->keyspace; + if (!$keyspace) { + throw new \InvalidArgumentException('Keyspace is required.'); + } + + $whereClause = []; + foreach (array_keys($where) as $column) { + $whereClause[] = "{$column} = ?"; + } + + $cql = "DELETE FROM {$keyspace}.{$tableName} WHERE ".implode(' AND ', $whereClause); + + $result = $this->__invoke($cql, array_values($where)); + + return [ + 'success' => $result['success'], + 'table' => $tableName, + 'keyspace' => $keyspace, + 'message' => $result['success'] ? 'Data deleted successfully' : 'Failed to delete data', + 'error' => $result['error'], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'table' => $tableName, + 'keyspace' => $keyspace, + 'message' => 'Error deleting data', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Describe Cassandra keyspaces. + * + * @return array{ + * success: bool, + * keyspaces: array, + * }>, + * error: string, + * } + */ + public function describeKeyspaces(): array + { + try { + $result = $this->__invoke('DESCRIBE KEYSPACES;'); + + // Parse keyspace information (simplified) + $keyspaces = []; + foreach ($result['results'] as $row) { + $keyspaces[] = [ + 'name' => $row['keyspace_name'] ?? '', + 'durableWrites' => $row['durable_writes'] ?? true, + 'replication' => $row['replication'] ?? [], + ]; + } + + return [ + 'success' => $result['success'], + 'keyspaces' => $keyspaces, + 'error' => $result['error'], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'keyspaces' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Describe Cassandra tables. + * + * @param string $keyspace Keyspace name + * + * @return array{ + * success: bool, + * tables: array, + * }>, + * error: string, + * } + */ + public function describeTables(string $keyspace = ''): array + { + try { + $keyspace = $keyspace ?: $this->keyspace; + if (!$keyspace) { + throw new \InvalidArgumentException('Keyspace is required.'); + } + + $result = $this->__invoke("DESCRIBE TABLES FROM {$keyspace};"); + + // Parse table information (simplified) + $tables = []; + foreach ($result['results'] as $row) { + $tables[] = [ + 'name' => $row['table_name'] ?? '', + 'keyspace' => $keyspace, + 'columns' => [], // Would need separate DESCRIBE TABLE command + ]; + } + + return [ + 'success' => $result['success'], + 'tables' => $tables, + 'error' => $result['error'], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'tables' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Build cqlsh command. + */ + private function buildCqlshCommand(string $cql, array $params): string + { + $command = "cqlsh {$this->host} {$this->port}"; + + if ($this->username) { + $command .= " -u {$this->username}"; + } + + if ($this->password) { + $command .= " -p {$this->password}"; + } + + if ($this->keyspace) { + $command .= " -k {$this->keyspace}"; + } + + $command .= ' -e "'.addslashes($cql).'"'; + + return $command; + } + + /** + * Execute command. + */ + private function executeCommand(string $command): string + { + $output = []; + $returnCode = 0; + + exec("{$command} 2>&1", $output, $returnCode); + + if (0 !== $returnCode) { + throw new \RuntimeException('CQL command failed: '.implode("\n", $output)); + } + + return implode("\n", $output); + } + + /** + * Parse CQL output. + */ + private function parseCqlOutput(string $output): array + { + // This is a simplified parser + // In reality, you would need more sophisticated parsing + return [ + 'data' => [], + 'columns' => [], + ]; + } +} diff --git a/src/agent/src/Toolbox/Tool/ClickUp.php b/src/agent/src/Toolbox/Tool/ClickUp.php new file mode 100644 index 000000000..89c96758d --- /dev/null +++ b/src/agent/src/Toolbox/Tool/ClickUp.php @@ -0,0 +1,537 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('clickup_get_tasks', 'Tool that gets ClickUp tasks')] +#[AsTool('clickup_create_task', 'Tool that creates ClickUp tasks', method: 'createTask')] +#[AsTool('clickup_get_lists', 'Tool that gets ClickUp lists', method: 'getLists')] +#[AsTool('clickup_get_folders', 'Tool that gets ClickUp folders', method: 'getFolders')] +#[AsTool('clickup_get_spaces', 'Tool that gets ClickUp spaces', method: 'getSpaces')] +#[AsTool('clickup_update_task', 'Tool that updates ClickUp tasks', method: 'updateTask')] +final readonly class ClickUp +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiToken, + private string $apiVersion = 'v2', + private array $options = [], + ) { + } + + /** + * Get ClickUp tasks. + * + * @param string $listId List ID to filter tasks + * @param string $assignee Assignee ID + * @param string $status Task status + * @param bool $includeClosed Include closed tasks + * @param int $page Page number + * @param string $orderBy Order by field + * @param bool $reverse Reverse order + * + * @return array, + * watchers: array, + * checklists: array, + * tags: array, + * url: string, + * list: array{id: string, name: string}, + * folder: array{id: string, name: string}, + * space: array{id: string}, + * }>|string + */ + public function __invoke( + string $listId = '', + string $assignee = '', + string $status = '', + bool $includeClosed = false, + int $page = 0, + string $orderBy = 'created', + bool $reverse = false, + ): array|string { + try { + $params = [ + 'page' => $page, + 'order_by' => $orderBy, + 'reverse' => $reverse, + 'include_closed' => $includeClosed, + ]; + + if ($assignee) { + $params['assignees'] = [$assignee]; + } + + $url = $listId + ? "https://api.clickup.com/api/{$this->apiVersion}/list/{$listId}/task" + : "https://api.clickup.com/api/{$this->apiVersion}/team/{$this->options['team_id']}/task"; + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => [ + 'Authorization' => $this->apiToken, + ], + 'query' => array_merge($params, $this->options), + ]); + + $data = $response->toArray(); + + if (isset($data['err'])) { + return 'Error getting tasks: '.($data['err'] ?? 'Unknown error'); + } + + $tasks = $data['tasks'] ?? []; + + return array_map(fn ($task) => [ + 'id' => $task['id'], + 'name' => $task['name'], + 'description' => $task['description'] ?? '', + 'status' => $task['status'], + 'orderindex' => $task['orderindex'], + 'date_created' => $task['date_created'], + 'date_updated' => $task['date_updated'], + 'date_closed' => $task['date_closed'], + 'assignees' => array_map(fn ($assignee) => [ + 'id' => $assignee['id'], + 'username' => $assignee['username'], + 'email' => $assignee['email'], + 'color' => $assignee['color'], + ], $task['assignees'] ?? []), + 'watchers' => $task['watchers'] ?? [], + 'checklists' => $task['checklists'] ?? [], + 'tags' => array_map(fn ($tag) => [ + 'name' => $tag['name'], + 'tag_fg' => $tag['tag_fg'], + 'tag_bg' => $tag['tag_bg'], + ], $task['tags'] ?? []), + 'url' => $task['url'], + 'list' => [ + 'id' => $task['list']['id'], + 'name' => $task['list']['name'], + ], + 'folder' => [ + 'id' => $task['folder']['id'], + 'name' => $task['folder']['name'], + ], + 'space' => [ + 'id' => $task['space']['id'], + ], + ], $tasks); + } catch (\Exception $e) { + return 'Error getting tasks: '.$e->getMessage(); + } + } + + /** + * Create a ClickUp task. + * + * @param string $listId List ID + * @param string $name Task name + * @param string $description Task description + * @param array $assignees Assignee IDs + * @param array $tags Tag names + * @param string $status Task status + * @param int $priority Priority (1=urgent, 2=high, 3=normal, 4=low) + * @param string $dueDate Due date + * + * @return array{ + * id: string, + * name: string, + * description: string, + * status: array{status: string, color: string, type: string, orderindex: int}, + * orderindex: string, + * date_created: string, + * date_updated: string, + * assignees: array, + * tags: array, + * url: string, + * list: array{id: string, name: string}, + * folder: array{id: string, name: string}, + * space: array{id: string}, + * }|string + */ + public function createTask( + string $listId, + string $name, + string $description = '', + array $assignees = [], + array $tags = [], + string $status = '', + int $priority = 3, + string $dueDate = '', + ): array|string { + try { + $payload = [ + 'name' => $name, + 'description' => $description, + 'assignees' => $assignees, + 'tags' => $tags, + 'priority' => $priority, + ]; + + if ($status) { + $payload['status'] = $status; + } + if ($dueDate) { + $payload['due_date'] = $dueDate; + } + + $response = $this->httpClient->request('POST', "https://api.clickup.com/api/{$this->apiVersion}/list/{$listId}/task", [ + 'headers' => [ + 'Authorization' => $this->apiToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['err'])) { + return 'Error creating task: '.($data['err'] ?? 'Unknown error'); + } + + $task = $data; + + return [ + 'id' => $task['id'], + 'name' => $task['name'], + 'description' => $task['description'] ?? '', + 'status' => $task['status'], + 'orderindex' => $task['orderindex'], + 'date_created' => $task['date_created'], + 'date_updated' => $task['date_updated'], + 'assignees' => array_map(fn ($assignee) => [ + 'id' => $assignee['id'], + 'username' => $assignee['username'], + 'email' => $assignee['email'], + 'color' => $assignee['color'], + ], $task['assignees'] ?? []), + 'tags' => array_map(fn ($tag) => [ + 'name' => $tag['name'], + 'tag_fg' => $tag['tag_fg'], + 'tag_bg' => $tag['tag_bg'], + ], $task['tags'] ?? []), + 'url' => $task['url'], + 'list' => [ + 'id' => $task['list']['id'], + 'name' => $task['list']['name'], + ], + 'folder' => [ + 'id' => $task['folder']['id'], + 'name' => $task['folder']['name'], + ], + 'space' => [ + 'id' => $task['space']['id'], + ], + ]; + } catch (\Exception $e) { + return 'Error creating task: '.$e->getMessage(); + } + } + + /** + * Get ClickUp lists. + * + * @param string $folderId Folder ID to filter lists + * @param bool $archived Include archived lists + * + * @return array|null, + * priority: array|null, + * assignee: array|null, + * task_count: int, + * due_date: string|null, + * start_date: string|null, + * folder: array{id: string, name: string, hidden: bool, access: bool}, + * space: array{id: string, name: string, access: bool}, + * statuses: array, + * permission_level: string, + * }>|string + */ + public function getLists( + string $folderId = '', + bool $archived = false, + ): array|string { + try { + $params = [ + 'archived' => $archived, + ]; + + $url = $folderId + ? "https://api.clickup.com/api/{$this->apiVersion}/folder/{$folderId}/list" + : "https://api.clickup.com/api/{$this->apiVersion}/team/{$this->options['team_id']}/list"; + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => [ + 'Authorization' => $this->apiToken, + ], + 'query' => array_merge($params, $this->options), + ]); + + $data = $response->toArray(); + + if (isset($data['err'])) { + return 'Error getting lists: '.($data['err'] ?? 'Unknown error'); + } + + $lists = $data['lists'] ?? []; + + return array_map(fn ($list) => [ + 'id' => $list['id'], + 'name' => $list['name'], + 'orderindex' => $list['orderindex'], + 'status' => $list['status'], + 'priority' => $list['priority'], + 'assignee' => $list['assignee'], + 'task_count' => $list['task_count'], + 'due_date' => $list['due_date'], + 'start_date' => $list['start_date'], + 'folder' => [ + 'id' => $list['folder']['id'], + 'name' => $list['folder']['name'], + 'hidden' => $list['folder']['hidden'], + 'access' => $list['folder']['access'], + ], + 'space' => [ + 'id' => $list['space']['id'], + 'name' => $list['space']['name'], + 'access' => $list['space']['access'], + ], + 'statuses' => array_map(fn ($status) => [ + 'status' => $status['status'], + 'type' => $status['type'], + 'orderindex' => $status['orderindex'], + 'color' => $status['color'], + ], $list['statuses'] ?? []), + 'permission_level' => $list['permission_level'], + ], $lists); + } catch (\Exception $e) { + return 'Error getting lists: '.$e->getMessage(); + } + } + + /** + * Get ClickUp folders. + * + * @param string $spaceId Space ID to filter folders + * @param bool $archived Include archived folders + * + * @return array, + * lists: array, + * permission_level: string, + * }>|string + */ + public function getFolders( + string $spaceId = '', + bool $archived = false, + ): array|string { + try { + $params = [ + 'archived' => $archived, + ]; + + $url = $spaceId + ? "https://api.clickup.com/api/{$this->apiVersion}/space/{$spaceId}/folder" + : "https://api.clickup.com/api/{$this->apiVersion}/team/{$this->options['team_id']}/folder"; + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => [ + 'Authorization' => $this->apiToken, + ], + 'query' => array_merge($params, $this->options), + ]); + + $data = $response->toArray(); + + if (isset($data['err'])) { + return 'Error getting folders: '.($data['err'] ?? 'Unknown error'); + } + + $folders = $data['folders'] ?? []; + + return array_map(fn ($folder) => [ + 'id' => $folder['id'], + 'name' => $folder['name'], + 'orderindex' => $folder['orderindex'], + 'override_statuses' => $folder['override_statuses'], + 'hidden' => $folder['hidden'], + 'space' => [ + 'id' => $folder['space']['id'], + 'name' => $folder['space']['name'], + 'access' => $folder['space']['access'], + ], + 'task_count' => $folder['task_count'], + 'archived' => $folder['archived'], + 'statuses' => array_map(fn ($status) => [ + 'status' => $status['status'], + 'type' => $status['type'], + 'orderindex' => $status['orderindex'], + 'color' => $status['color'], + ], $folder['statuses'] ?? []), + 'lists' => array_map(fn ($list) => [ + 'id' => $list['id'], + 'name' => $list['name'], + 'access' => $list['access'], + ], $folder['lists'] ?? []), + 'permission_level' => $folder['permission_level'], + ], $folders); + } catch (\Exception $e) { + return 'Error getting folders: '.$e->getMessage(); + } + } + + /** + * Get ClickUp spaces. + * + * @param bool $archived Include archived spaces + * + * @return array, + * multiple_assignees: bool, + * features: array, + * archived: bool, + * }>|string + */ + public function getSpaces(bool $archived = false): array|string + { + try { + $params = [ + 'archived' => $archived, + ]; + + $response = $this->httpClient->request('GET', "https://api.clickup.com/api/{$this->apiVersion}/team/{$this->options['team_id']}/space", [ + 'headers' => [ + 'Authorization' => $this->apiToken, + ], + 'query' => array_merge($params, $this->options), + ]); + + $data = $response->toArray(); + + if (isset($data['err'])) { + return 'Error getting spaces: '.($data['err'] ?? 'Unknown error'); + } + + $spaces = $data['spaces'] ?? []; + + return array_map(fn ($space) => [ + 'id' => $space['id'], + 'name' => $space['name'], + 'private' => $space['private'], + 'color' => $space['color'], + 'avatar' => $space['avatar'], + 'admin_can_manage' => $space['admin_can_manage'], + 'statuses' => array_map(fn ($status) => [ + 'status' => $status['status'], + 'type' => $status['type'], + 'orderindex' => $status['orderindex'], + 'color' => $status['color'], + ], $space['statuses'] ?? []), + 'multiple_assignees' => $space['multiple_assignees'], + 'features' => $space['features'], + 'archived' => $space['archived'], + ], $spaces); + } catch (\Exception $e) { + return 'Error getting spaces: '.$e->getMessage(); + } + } + + /** + * Update a ClickUp task. + * + * @param string $taskId Task ID to update + * @param string $name New name (optional) + * @param string $description New description (optional) + * @param string $status New status (optional) + * @param array $assignees New assignees (optional) + * @param int $priority New priority (optional) + */ + public function updateTask( + string $taskId, + string $name = '', + string $description = '', + string $status = '', + array $assignees = [], + int $priority = -1, + ): string { + try { + $payload = []; + + if ($name) { + $payload['name'] = $name; + } + if ($description) { + $payload['description'] = $description; + } + if ($status) { + $payload['status'] = $status; + } + if (!empty($assignees)) { + $payload['assignees'] = $assignees; + } + if ($priority >= 0) { + $payload['priority'] = $priority; + } + + $response = $this->httpClient->request('PUT', "https://api.clickup.com/api/{$this->apiVersion}/task/{$taskId}", [ + 'headers' => [ + 'Authorization' => $this->apiToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['err'])) { + return 'Error updating task: '.($data['err'] ?? 'Unknown error'); + } + + return 'Task updated successfully'; + } catch (\Exception $e) { + return 'Error updating task: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Cloudflare.php b/src/agent/src/Toolbox/Tool/Cloudflare.php new file mode 100644 index 000000000..6fff37344 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Cloudflare.php @@ -0,0 +1,719 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('cloudflare_get_zones', 'Tool that gets Cloudflare zones')] +#[AsTool('cloudflare_get_dns_records', 'Tool that gets Cloudflare DNS records', method: 'getDnsRecords')] +#[AsTool('cloudflare_create_dns_record', 'Tool that creates Cloudflare DNS records', method: 'createDnsRecord')] +#[AsTool('cloudflare_get_analytics', 'Tool that gets Cloudflare analytics', method: 'getAnalytics')] +#[AsTool('cloudflare_get_firewall_rules', 'Tool that gets Cloudflare firewall rules', method: 'getFirewallRules')] +#[AsTool('cloudflare_get_ssl_settings', 'Tool that gets Cloudflare SSL settings', method: 'getSslSettings')] +final readonly class Cloudflare +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiToken, + private string $apiVersion = 'v4', + private array $options = [], + ) { + } + + /** + * Get Cloudflare zones. + * + * @param string $name Zone name filter + * @param string $status Zone status filter (active, pending, initializing, moved, deleted, deactivated) + * @param int $perPage Number of zones per page + * @param int $page Page number + * @param string $order Order by field (name, status, account) + * @param string $direction Order direction (asc, desc) + * @param string $match Match type (all, any) + * + * @return array, + * original_name_servers: array, + * original_registrar: string, + * original_dnshost: string, + * modified_on: string, + * created_on: string, + * activated_on: string, + * meta: array{ + * step: int, + * custom_certificate_quota: int, + * page_rule_quota: int, + * phishing_detected: bool, + * multiple_railguns_allowed: bool, + * }, + * owner: array{ + * id: string, + * type: string, + * email: string, + * }, + * account: array{ + * id: string, + * name: string, + * }, + * permissions: array, + * plan: array{ + * id: string, + * name: string, + * price: int, + * currency: string, + * frequency: string, + * is_subscribed: bool, + * can_subscribe: bool, + * legacy_id: string, + * legacy_discount: bool, + * externally_managed: bool, + * }, + * }> + */ + public function __invoke( + string $name = '', + string $status = '', + int $perPage = 50, + int $page = 1, + string $order = 'name', + string $direction = 'asc', + string $match = 'all', + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + 'order' => $order, + 'direction' => $direction, + 'match' => $match, + ]; + + if ($name) { + $params['name'] = $name; + } + if ($status) { + $params['status'] = $status; + } + + $response = $this->httpClient->request('GET', "https://api.cloudflare.com/client/{$this->apiVersion}/zones", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiToken, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['success']) && !$data['success']) { + return []; + } + + return array_map(fn ($zone) => [ + 'id' => $zone['id'], + 'name' => $zone['name'], + 'status' => $zone['status'], + 'paused' => $zone['paused'], + 'type' => $zone['type'], + 'development_mode' => $zone['development_mode'], + 'name_servers' => $zone['name_servers'], + 'original_name_servers' => $zone['original_name_servers'], + 'original_registrar' => $zone['original_registrar'], + 'original_dnshost' => $zone['original_dnshost'], + 'modified_on' => $zone['modified_on'], + 'created_on' => $zone['created_on'], + 'activated_on' => $zone['activated_on'], + 'meta' => [ + 'step' => $zone['meta']['step'], + 'custom_certificate_quota' => $zone['meta']['custom_certificate_quota'], + 'page_rule_quota' => $zone['meta']['page_rule_quota'], + 'phishing_detected' => $zone['meta']['phishing_detected'], + 'multiple_railguns_allowed' => $zone['meta']['multiple_railguns_allowed'], + ], + 'owner' => [ + 'id' => $zone['owner']['id'], + 'type' => $zone['owner']['type'], + 'email' => $zone['owner']['email'], + ], + 'account' => [ + 'id' => $zone['account']['id'], + 'name' => $zone['account']['name'], + ], + 'permissions' => $zone['permissions'], + 'plan' => [ + 'id' => $zone['plan']['id'], + 'name' => $zone['plan']['name'], + 'price' => $zone['plan']['price'], + 'currency' => $zone['plan']['currency'], + 'frequency' => $zone['plan']['frequency'], + 'is_subscribed' => $zone['plan']['is_subscribed'], + 'can_subscribe' => $zone['plan']['can_subscribe'], + 'legacy_id' => $zone['plan']['legacy_id'], + 'legacy_discount' => $zone['plan']['legacy_discount'], + 'externally_managed' => $zone['plan']['externally_managed'], + ], + ], $data['result'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Cloudflare DNS records. + * + * @param string $zoneId Zone ID + * @param string $type Record type (A, AAAA, CNAME, MX, TXT, etc.) + * @param string $name Record name + * @param string $content Record content + * @param int $perPage Number of records per page + * @param int $page Page number + * @param string $order Order by field (type, name, content, ttl, proxied) + * @param string $direction Order direction (asc, desc) + * @param string $match Match type (all, any) + * + * @return array, + * created_on: string, + * modified_on: string, + * priority: int|null, + * }> + */ + public function getDnsRecords( + string $zoneId, + string $type = '', + string $name = '', + string $content = '', + int $perPage = 50, + int $page = 1, + string $order = 'type', + string $direction = 'asc', + string $match = 'all', + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + 'order' => $order, + 'direction' => $direction, + 'match' => $match, + ]; + + if ($type) { + $params['type'] = $type; + } + if ($name) { + $params['name'] = $name; + } + if ($content) { + $params['content'] = $content; + } + + $response = $this->httpClient->request('GET', "https://api.cloudflare.com/client/{$this->apiVersion}/zones/{$zoneId}/dns_records", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiToken, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['success']) && !$data['success']) { + return []; + } + + return array_map(fn ($record) => [ + 'id' => $record['id'], + 'zone_id' => $record['zone_id'], + 'zone_name' => $record['zone_name'], + 'name' => $record['name'], + 'type' => $record['type'], + 'content' => $record['content'], + 'proxiable' => $record['proxiable'], + 'proxied' => $record['proxied'] ?? false, + 'ttl' => $record['ttl'], + 'locked' => $record['locked'], + 'meta' => [ + 'auto_added' => $record['meta']['auto_added'], + 'managed_by_apps' => $record['meta']['managed_by_apps'], + 'managed_by_argo_tunnel' => $record['meta']['managed_by_argo_tunnel'], + 'source' => $record['meta']['source'], + ], + 'comment' => $record['comment'] ?? '', + 'tags' => $record['tags'] ?? [], + 'created_on' => $record['created_on'], + 'modified_on' => $record['modified_on'], + 'priority' => $record['priority'], + ], $data['result'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a Cloudflare DNS record. + * + * @param string $zoneId Zone ID + * @param string $type Record type (A, AAAA, CNAME, MX, TXT, etc.) + * @param string $name Record name + * @param string $content Record content + * @param int $ttl TTL value (1 for auto, 120-86400 for manual) + * @param bool $proxied Whether record is proxied through Cloudflare + * @param int $priority Priority (for MX records) + * @param string $comment Record comment + * + * @return array{ + * id: string, + * zone_id: string, + * zone_name: string, + * name: string, + * type: string, + * content: string, + * proxiable: bool, + * proxied: bool, + * ttl: int, + * locked: bool, + * meta: array{ + * auto_added: bool, + * managed_by_apps: bool, + * managed_by_argo_tunnel: bool, + * source: string, + * }, + * comment: string, + * tags: array, + * created_on: string, + * modified_on: string, + * priority: int|null, + * }|string + */ + public function createDnsRecord( + string $zoneId, + string $type, + string $name, + string $content, + int $ttl = 1, + bool $proxied = false, + int $priority = 0, + string $comment = '', + ): array|string { + try { + $payload = [ + 'type' => $type, + 'name' => $name, + 'content' => $content, + 'ttl' => $ttl, + 'proxied' => $proxied, + ]; + + if ($priority > 0) { + $payload['priority'] = $priority; + } + if ($comment) { + $payload['comment'] = $comment; + } + + $response = $this->httpClient->request('POST', "https://api.cloudflare.com/client/{$this->apiVersion}/zones/{$zoneId}/dns_records", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['success']) && !$data['success']) { + return 'Error creating DNS record: '.($data['errors'][0]['message'] ?? 'Unknown error'); + } + + $record = $data['result']; + + return [ + 'id' => $record['id'], + 'zone_id' => $record['zone_id'], + 'zone_name' => $record['zone_name'], + 'name' => $record['name'], + 'type' => $record['type'], + 'content' => $record['content'], + 'proxiable' => $record['proxiable'], + 'proxied' => $record['proxied'] ?? false, + 'ttl' => $record['ttl'], + 'locked' => $record['locked'], + 'meta' => [ + 'auto_added' => $record['meta']['auto_added'], + 'managed_by_apps' => $record['meta']['managed_by_apps'], + 'managed_by_argo_tunnel' => $record['meta']['managed_by_argo_tunnel'], + 'source' => $record['meta']['source'], + ], + 'comment' => $record['comment'] ?? '', + 'tags' => $record['tags'] ?? [], + 'created_on' => $record['created_on'], + 'modified_on' => $record['modified_on'], + 'priority' => $record['priority'], + ]; + } catch (\Exception $e) { + return 'Error creating DNS record: '.$e->getMessage(); + } + } + + /** + * Get Cloudflare analytics. + * + * @param string $zoneId Zone ID + * @param string $since Start date (ISO 8601) + * @param string $until End date (ISO 8601) + * @param string $continuous Continuous data (true/false) + * + * @return array{ + * totals: array{ + * requests: array{ + * all: int, + * cached: int, + * uncached: int, + * }, + * bandwidth: array{ + * all: int, + * cached: int, + * uncached: int, + * }, + * threats: array{ + * all: int, + * type: array, + * }, + * pageviews: array{ + * all: int, + * }, + * uniques: array{ + * all: int, + * }, + * }, + * timeseries: array, + * }|string + */ + public function getAnalytics( + string $zoneId, + string $since, + string $until, + string $continuous = 'false', + ): array|string { + try { + $params = [ + 'since' => $since, + 'until' => $until, + 'continuous' => $continuous, + ]; + + $response = $this->httpClient->request('GET', "https://api.cloudflare.com/client/{$this->apiVersion}/zones/{$zoneId}/analytics/dashboard", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiToken, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['success']) && !$data['success']) { + return 'Error getting analytics: '.($data['errors'][0]['message'] ?? 'Unknown error'); + } + + $result = $data['result']; + + return [ + 'totals' => [ + 'requests' => [ + 'all' => $result['totals']['requests']['all'], + 'cached' => $result['totals']['requests']['cached'], + 'uncached' => $result['totals']['requests']['uncached'], + ], + 'bandwidth' => [ + 'all' => $result['totals']['bandwidth']['all'], + 'cached' => $result['totals']['bandwidth']['cached'], + 'uncached' => $result['totals']['bandwidth']['uncached'], + ], + 'threats' => [ + 'all' => $result['totals']['threats']['all'], + 'type' => $result['totals']['threats']['type'] ?? [], + ], + 'pageviews' => [ + 'all' => $result['totals']['pageviews']['all'], + ], + 'uniques' => [ + 'all' => $result['totals']['uniques']['all'], + ], + ], + 'timeseries' => array_map(fn ($point) => [ + 'since' => $point['since'], + 'until' => $point['until'], + 'requests' => [ + 'all' => $point['requests']['all'], + 'cached' => $point['requests']['cached'], + 'uncached' => $point['requests']['uncached'], + ], + 'bandwidth' => [ + 'all' => $point['bandwidth']['all'], + 'cached' => $point['bandwidth']['cached'], + 'uncached' => $point['bandwidth']['uncached'], + ], + 'threats' => [ + 'all' => $point['threats']['all'], + ], + 'pageviews' => [ + 'all' => $point['pageviews']['all'], + ], + 'uniques' => [ + 'all' => $point['uniques']['all'], + ], + ], $result['timeseries'] ?? []), + ]; + } catch (\Exception $e) { + return 'Error getting analytics: '.$e->getMessage(); + } + } + + /** + * Get Cloudflare firewall rules. + * + * @param string $zoneId Zone ID + * @param int $perPage Number of rules per page + * @param int $page Page number + * @param string $order Order by field (priority, description, action) + * @param string $direction Order direction (asc, desc) + * + * @return array + */ + public function getFirewallRules( + string $zoneId, + int $perPage = 50, + int $page = 1, + string $order = 'priority', + string $direction = 'desc', + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + 'order' => $order, + 'direction' => $direction, + ]; + + $response = $this->httpClient->request('GET', "https://api.cloudflare.com/client/{$this->apiVersion}/zones/{$zoneId}/firewall/rules", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiToken, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['success']) && !$data['success']) { + return []; + } + + return array_map(fn ($rule) => [ + 'id' => $rule['id'], + 'paused' => $rule['paused'], + 'description' => $rule['description'], + 'action' => $rule['action'], + 'priority' => $rule['priority'], + 'filter' => [ + 'id' => $rule['filter']['id'], + 'paused' => $rule['filter']['paused'], + 'description' => $rule['filter']['description'], + 'expression' => $rule['filter']['expression'], + 'ref' => $rule['filter']['ref'], + ], + 'created_on' => $rule['created_on'], + 'modified_on' => $rule['modified_on'], + ], $data['result'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Cloudflare SSL settings. + * + * @param string $zoneId Zone ID + * + * @return array{ + * id: string, + * status: string, + * method: string, + * type: string, + * settings: array{ + * min_tls_version: string, + * ciphers: array, + * early_hints: string, + * tls_1_3: string, + * automatic_https_rewrites: string, + * opportunistic_encryption: string, + * minify: array{ + * css: string, + * html: string, + * js: string, + * }, + * mirage: string, + * polish: string, + * webp: string, + * brotli: string, + * rocket_loader: string, + * security_level: string, + * development_mode: int, + * security_headers: array{ + * strict_transport_security: array{ + * enabled: bool, + * max_age: int, + * include_subdomains: bool, + * nosniff: bool, + * }, + * }, + * edge_cache_ttl: int, + * browser_cache_ttl: int, + * always_online: string, + * cache_level: string, + * }, + * created_on: string, + * modified_on: string, + * }|string + */ + public function getSslSettings(string $zoneId): array|string + { + try { + $response = $this->httpClient->request('GET', "https://api.cloudflare.com/client/{$this->apiVersion}/zones/{$zoneId}/settings/ssl", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiToken, + 'Content-Type' => 'application/json', + ], + ]); + + $data = $response->toArray(); + + if (isset($data['success']) && !$data['success']) { + return 'Error getting SSL settings: '.($data['errors'][0]['message'] ?? 'Unknown error'); + } + + $result = $data['result']; + + return [ + 'id' => $result['id'], + 'status' => $result['status'], + 'method' => $result['method'], + 'type' => $result['type'], + 'settings' => [ + 'min_tls_version' => $result['settings']['min_tls_version'], + 'ciphers' => $result['settings']['ciphers'], + 'early_hints' => $result['settings']['early_hints'], + 'tls_1_3' => $result['settings']['tls_1_3'], + 'automatic_https_rewrites' => $result['settings']['automatic_https_rewrites'], + 'opportunistic_encryption' => $result['settings']['opportunistic_encryption'], + 'minify' => [ + 'css' => $result['settings']['minify']['css'], + 'html' => $result['settings']['minify']['html'], + 'js' => $result['settings']['minify']['js'], + ], + 'mirage' => $result['settings']['mirage'], + 'polish' => $result['settings']['polish'], + 'webp' => $result['settings']['webp'], + 'brotli' => $result['settings']['brotli'], + 'rocket_loader' => $result['settings']['rocket_loader'], + 'security_level' => $result['settings']['security_level'], + 'development_mode' => $result['settings']['development_mode'], + 'security_headers' => [ + 'strict_transport_security' => [ + 'enabled' => $result['settings']['security_headers']['strict_transport_security']['enabled'], + 'max_age' => $result['settings']['security_headers']['strict_transport_security']['max_age'], + 'include_subdomains' => $result['settings']['security_headers']['strict_transport_security']['include_subdomains'], + 'nosniff' => $result['settings']['security_headers']['strict_transport_security']['nosniff'], + ], + ], + 'edge_cache_ttl' => $result['settings']['edge_cache_ttl'], + 'browser_cache_ttl' => $result['settings']['browser_cache_ttl'], + 'always_online' => $result['settings']['always_online'], + 'cache_level' => $result['settings']['cache_level'], + ], + 'created_on' => $result['created_on'], + 'modified_on' => $result['modified_on'], + ]; + } catch (\Exception $e) { + return 'Error getting SSL settings: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Cogniswitch.php b/src/agent/src/Toolbox/Tool/Cogniswitch.php new file mode 100644 index 000000000..c10b55e91 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Cogniswitch.php @@ -0,0 +1,884 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('cogniswitch_extract', 'Tool that extracts knowledge using Cogniswitch')] +#[AsTool('cogniswitch_search', 'Tool that searches knowledge base', method: 'search')] +#[AsTool('cogniswitch_analyze', 'Tool that analyzes content', method: 'analyze')] +#[AsTool('cogniswitch_synthesize', 'Tool that synthesizes information', method: 'synthesize')] +#[AsTool('cogniswitch_validate', 'Tool that validates knowledge', method: 'validate')] +#[AsTool('cogniswitch_enrich', 'Tool that enriches content', method: 'enrich')] +#[AsTool('cogniswitch_classify', 'Tool that classifies content', method: 'classify')] +#[AsTool('cogniswitch_summarize', 'Tool that summarizes content', method: 'summarize')] +final readonly class Cogniswitch +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.cogniswitch.ai/v1', + private array $options = [], + ) { + } + + /** + * Extract knowledge using Cogniswitch. + * + * @param string $content Content to extract knowledge from + * @param string $extractionType Type of extraction + * @param array $options Extraction options + * @param array $context Extraction context + * + * @return array{ + * success: bool, + * extraction: array{ + * content: string, + * extraction_type: string, + * knowledge_items: array, + * relations: array, + * }>, + * summary: string, + * metadata: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $content, + string $extractionType = 'entities_relations', + array $options = [], + array $context = [], + ): array { + try { + $requestData = [ + 'content' => $content, + 'extraction_type' => $extractionType, + 'options' => $options, + 'context' => $context, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/extract", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $extraction = $responseData['extraction'] ?? []; + + return [ + 'success' => true, + 'extraction' => [ + 'content' => $content, + 'extraction_type' => $extractionType, + 'knowledge_items' => array_map(fn ($item) => [ + 'id' => $item['id'] ?? '', + 'type' => $item['type'] ?? '', + 'content' => $item['content'] ?? '', + 'confidence' => $item['confidence'] ?? 0.0, + 'source' => [ + 'start' => $item['source']['start'] ?? 0, + 'end' => $item['source']['end'] ?? 0, + 'text' => $item['source']['text'] ?? '', + ], + 'entities' => array_map(fn ($entity) => [ + 'name' => $entity['name'] ?? '', + 'type' => $entity['type'] ?? '', + 'confidence' => $entity['confidence'] ?? 0.0, + ], $item['entities'] ?? []), + 'relations' => array_map(fn ($relation) => [ + 'subject' => $relation['subject'] ?? '', + 'predicate' => $relation['predicate'] ?? '', + 'object' => $relation['object'] ?? '', + 'confidence' => $relation['confidence'] ?? 0.0, + ], $item['relations'] ?? []), + ], $extraction['knowledge_items'] ?? []), + 'summary' => $extraction['summary'] ?? '', + 'metadata' => $extraction['metadata'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'extraction' => [ + 'content' => $content, + 'extraction_type' => $extractionType, + 'knowledge_items' => [], + 'summary' => '', + 'metadata' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search knowledge base. + * + * @param string $query Search query + * @param array $filters Search filters + * @param string $searchType Type of search + * @param int $limit Number of results to return + * + * @return array{ + * success: bool, + * search_results: array{ + * query: string, + * search_type: string, + * results: array, + * entities: array, + * relations: array, + * }>, + * total_results: int, + * facets: array>, + * suggestions: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function search( + string $query, + array $filters = [], + string $searchType = 'semantic', + int $limit = 10, + ): array { + try { + $requestData = [ + 'query' => $query, + 'filters' => $filters, + 'search_type' => $searchType, + 'limit' => max(1, min($limit, 100)), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/search", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $searchResults = $responseData['search_results'] ?? []; + + return [ + 'success' => true, + 'search_results' => [ + 'query' => $query, + 'search_type' => $searchType, + 'results' => array_map(fn ($result) => [ + 'id' => $result['id'] ?? '', + 'title' => $result['title'] ?? '', + 'content' => $result['content'] ?? '', + 'relevance_score' => $result['relevance_score'] ?? 0.0, + 'source' => $result['source'] ?? '', + 'metadata' => $result['metadata'] ?? [], + 'entities' => array_map(fn ($entity) => [ + 'name' => $entity['name'] ?? '', + 'type' => $entity['type'] ?? '', + 'confidence' => $entity['confidence'] ?? 0.0, + ], $result['entities'] ?? []), + 'relations' => array_map(fn ($relation) => [ + 'subject' => $relation['subject'] ?? '', + 'predicate' => $relation['predicate'] ?? '', + 'object' => $relation['object'] ?? '', + ], $result['relations'] ?? []), + ], $searchResults['results'] ?? []), + 'total_results' => $searchResults['total_results'] ?? 0, + 'facets' => array_map(fn ($facet) => array_map(fn ($item) => [ + 'value' => $item['value'] ?? '', + 'count' => $item['count'] ?? 0, + ], $facet), $searchResults['facets'] ?? []), + 'suggestions' => $searchResults['suggestions'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'search_results' => [ + 'query' => $query, + 'search_type' => $searchType, + 'results' => [], + 'total_results' => 0, + 'facets' => [], + 'suggestions' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze content. + * + * @param string $content Content to analyze + * @param array $analysisTypes Types of analysis to perform + * @param array $options Analysis options + * + * @return array{ + * success: bool, + * analysis: array{ + * content: string, + * analysis_types: array, + * sentiment: array{ + * score: float, + * label: string, + * confidence: float, + * }, + * topics: array, + * entities: array, + * key_phrases: array, + * language: array{ + * code: string, + * confidence: float, + * }, + * readability: array{ + * score: float, + * level: string, + * metrics: array, + * }, + * insights: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function analyze( + string $content, + array $analysisTypes = ['sentiment', 'topics', 'entities', 'readability'], + array $options = [], + ): array { + try { + $requestData = [ + 'content' => $content, + 'analysis_types' => $analysisTypes, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/analyze", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $analysis = $responseData['analysis'] ?? []; + + return [ + 'success' => true, + 'analysis' => [ + 'content' => $content, + 'analysis_types' => $analysisTypes, + 'sentiment' => [ + 'score' => $analysis['sentiment']['score'] ?? 0.0, + 'label' => $analysis['sentiment']['label'] ?? 'neutral', + 'confidence' => $analysis['sentiment']['confidence'] ?? 0.0, + ], + 'topics' => array_map(fn ($topic) => [ + 'topic' => $topic['topic'] ?? '', + 'score' => $topic['score'] ?? 0.0, + 'relevance' => $topic['relevance'] ?? 0.0, + ], $analysis['topics'] ?? []), + 'entities' => array_map(fn ($entity) => [ + 'name' => $entity['name'] ?? '', + 'type' => $entity['type'] ?? '', + 'confidence' => $entity['confidence'] ?? 0.0, + 'frequency' => $entity['frequency'] ?? 0, + ], $analysis['entities'] ?? []), + 'key_phrases' => $analysis['key_phrases'] ?? [], + 'language' => [ + 'code' => $analysis['language']['code'] ?? 'en', + 'confidence' => $analysis['language']['confidence'] ?? 0.0, + ], + 'readability' => [ + 'score' => $analysis['readability']['score'] ?? 0.0, + 'level' => $analysis['readability']['level'] ?? 'intermediate', + 'metrics' => $analysis['readability']['metrics'] ?? [], + ], + 'insights' => $analysis['insights'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'analysis' => [ + 'content' => $content, + 'analysis_types' => $analysisTypes, + 'sentiment' => [ + 'score' => 0.0, + 'label' => 'neutral', + 'confidence' => 0.0, + ], + 'topics' => [], + 'entities' => [], + 'key_phrases' => [], + 'language' => [ + 'code' => 'en', + 'confidence' => 0.0, + ], + 'readability' => [ + 'score' => 0.0, + 'level' => 'intermediate', + 'metrics' => [], + ], + 'insights' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Synthesize information. + * + * @param array $sources Sources to synthesize + * @param string $synthesisType Type of synthesis + * @param array $options Synthesis options + * + * @return array{ + * success: bool, + * synthesis: array{ + * sources: array, + * synthesis_type: string, + * synthesized_content: string, + * key_points: array, + * contradictions: array, + * consensus_points: array, + * confidence_scores: array, + * metadata: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function synthesize( + array $sources, + string $synthesisType = 'comprehensive', + array $options = [], + ): array { + try { + $requestData = [ + 'sources' => $sources, + 'synthesis_type' => $synthesisType, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/synthesize", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $synthesis = $responseData['synthesis'] ?? []; + + return [ + 'success' => true, + 'synthesis' => [ + 'sources' => $sources, + 'synthesis_type' => $synthesisType, + 'synthesized_content' => $synthesis['synthesized_content'] ?? '', + 'key_points' => $synthesis['key_points'] ?? [], + 'contradictions' => array_map(fn ($contradiction) => [ + 'source1' => $contradiction['source1'] ?? '', + 'source2' => $contradiction['source2'] ?? '', + 'contradiction' => $contradiction['contradiction'] ?? '', + ], $synthesis['contradictions'] ?? []), + 'consensus_points' => $synthesis['consensus_points'] ?? [], + 'confidence_scores' => $synthesis['confidence_scores'] ?? [], + 'metadata' => $synthesis['metadata'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'synthesis' => [ + 'sources' => $sources, + 'synthesis_type' => $synthesisType, + 'synthesized_content' => '', + 'key_points' => [], + 'contradictions' => [], + 'consensus_points' => [], + 'confidence_scores' => [], + 'metadata' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Validate knowledge. + * + * @param string $content Content to validate + * @param array $validationCriteria Validation criteria + * @param array $referenceData Reference data for validation + * + * @return array{ + * success: bool, + * validation: array{ + * content: string, + * validation_criteria: array, + * is_valid: bool, + * confidence: float, + * issues: array, + * facts_check: array{ + * verified_facts: int, + * unverified_facts: int, + * contradicted_facts: int, + * }, + * sources_verification: array, + * recommendations: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function validate( + string $content, + array $validationCriteria = [], + array $referenceData = [], + ): array { + try { + $requestData = [ + 'content' => $content, + 'validation_criteria' => $validationCriteria, + 'reference_data' => $referenceData, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/validate", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $validation = $responseData['validation'] ?? []; + + return [ + 'success' => true, + 'validation' => [ + 'content' => $content, + 'validation_criteria' => $validationCriteria, + 'is_valid' => $validation['is_valid'] ?? false, + 'confidence' => $validation['confidence'] ?? 0.0, + 'issues' => array_map(fn ($issue) => [ + 'type' => $issue['type'] ?? '', + 'description' => $issue['description'] ?? '', + 'severity' => $issue['severity'] ?? 'low', + 'location' => [ + 'start' => $issue['location']['start'] ?? 0, + 'end' => $issue['location']['end'] ?? 0, + ], + 'suggestion' => $issue['suggestion'] ?? '', + ], $validation['issues'] ?? []), + 'facts_check' => [ + 'verified_facts' => $validation['facts_check']['verified_facts'] ?? 0, + 'unverified_facts' => $validation['facts_check']['unverified_facts'] ?? 0, + 'contradicted_facts' => $validation['facts_check']['contradicted_facts'] ?? 0, + ], + 'sources_verification' => array_map(fn ($source) => [ + 'source' => $source['source'] ?? '', + 'is_verified' => $source['is_verified'] ?? false, + 'confidence' => $source['confidence'] ?? 0.0, + ], $validation['sources_verification'] ?? []), + 'recommendations' => $validation['recommendations'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'validation' => [ + 'content' => $content, + 'validation_criteria' => $validationCriteria, + 'is_valid' => false, + 'confidence' => 0.0, + 'issues' => [], + 'facts_check' => [ + 'verified_facts' => 0, + 'unverified_facts' => 0, + 'contradicted_facts' => 0, + ], + 'sources_verification' => [], + 'recommendations' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Enrich content. + * + * @param string $content Content to enrich + * @param string $enrichmentType Type of enrichment + * @param array $options Enrichment options + * + * @return array{ + * success: bool, + * enrichment: array{ + * original_content: string, + * enriched_content: string, + * enrichment_type: string, + * added_elements: array, + * contextual_info: array, + * related_concepts: array, + * metadata: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function enrich( + string $content, + string $enrichmentType = 'contextual', + array $options = [], + ): array { + try { + $requestData = [ + 'content' => $content, + 'enrichment_type' => $enrichmentType, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/enrich", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $enrichment = $responseData['enrichment'] ?? []; + + return [ + 'success' => true, + 'enrichment' => [ + 'original_content' => $content, + 'enriched_content' => $enrichment['enriched_content'] ?? '', + 'enrichment_type' => $enrichmentType, + 'added_elements' => array_map(fn ($element) => [ + 'type' => $element['type'] ?? '', + 'content' => $element['content'] ?? '', + 'position' => $element['position'] ?? 0, + 'confidence' => $element['confidence'] ?? 0.0, + ], $enrichment['added_elements'] ?? []), + 'contextual_info' => array_map(fn ($info) => [ + 'topic' => $info['topic'] ?? '', + 'information' => $info['information'] ?? '', + 'relevance' => $info['relevance'] ?? 0.0, + ], $enrichment['contextual_info'] ?? []), + 'related_concepts' => array_map(fn ($concept) => [ + 'concept' => $concept['concept'] ?? '', + 'description' => $concept['description'] ?? '', + 'connection_strength' => $concept['connection_strength'] ?? 0.0, + ], $enrichment['related_concepts'] ?? []), + 'metadata' => $enrichment['metadata'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'enrichment' => [ + 'original_content' => $content, + 'enriched_content' => '', + 'enrichment_type' => $enrichmentType, + 'added_elements' => [], + 'contextual_info' => [], + 'related_concepts' => [], + 'metadata' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Classify content. + * + * @param string $content Content to classify + * @param array $classificationOptions Classification options + * + * @return array{ + * success: bool, + * classification: array{ + * content: string, + * primary_category: string, + * secondary_categories: array, + * confidence_scores: array, + * tags: array, + * attributes: array, + * reasoning: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function classify( + string $content, + array $classificationOptions = [], + ): array { + try { + $requestData = [ + 'content' => $content, + 'classification_options' => $classificationOptions, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/classify", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $classification = $responseData['classification'] ?? []; + + return [ + 'success' => true, + 'classification' => [ + 'content' => $content, + 'primary_category' => $classification['primary_category'] ?? '', + 'secondary_categories' => $classification['secondary_categories'] ?? [], + 'confidence_scores' => $classification['confidence_scores'] ?? [], + 'tags' => $classification['tags'] ?? [], + 'attributes' => $classification['attributes'] ?? [], + 'reasoning' => $classification['reasoning'] ?? '', + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'classification' => [ + 'content' => $content, + 'primary_category' => '', + 'secondary_categories' => [], + 'confidence_scores' => [], + 'tags' => [], + 'attributes' => [], + 'reasoning' => '', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Summarize content. + * + * @param string $content Content to summarize + * @param string $summaryType Type of summary + * @param int $maxLength Maximum summary length + * @param array $options Summary options + * + * @return array{ + * success: bool, + * summary: array{ + * original_content: string, + * summary: string, + * summary_type: string, + * max_length: int, + * compression_ratio: float, + * key_points: array, + * sentiment: string, + * topics: array, + * entities: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function summarize( + string $content, + string $summaryType = 'extractive', + int $maxLength = 200, + array $options = [], + ): array { + try { + $requestData = [ + 'content' => $content, + 'summary_type' => $summaryType, + 'max_length' => max(50, min($maxLength, 1000)), + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/summarize", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $summary = $responseData['summary'] ?? []; + + return [ + 'success' => true, + 'summary' => [ + 'original_content' => $content, + 'summary' => $summary['summary'] ?? '', + 'summary_type' => $summaryType, + 'max_length' => $maxLength, + 'compression_ratio' => $summary['compression_ratio'] ?? 0.0, + 'key_points' => $summary['key_points'] ?? [], + 'sentiment' => $summary['sentiment'] ?? 'neutral', + 'topics' => $summary['topics'] ?? [], + 'entities' => array_map(fn ($entity) => [ + 'name' => $entity['name'] ?? '', + 'type' => $entity['type'] ?? '', + 'mentions' => $entity['mentions'] ?? 0, + ], $summary['entities'] ?? []), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'summary' => [ + 'original_content' => $content, + 'summary' => '', + 'summary_type' => $summaryType, + 'max_length' => $maxLength, + 'compression_ratio' => 0.0, + 'key_points' => [], + 'sentiment' => 'neutral', + 'topics' => [], + 'entities' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Connery.php b/src/agent/src/Toolbox/Tool/Connery.php new file mode 100644 index 000000000..05f5aad61 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Connery.php @@ -0,0 +1,807 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('connery_execute', 'Tool that executes actions using Connery')] +#[AsTool('connery_list_actions', 'Tool that lists available actions', method: 'listActions')] +#[AsTool('connery_get_action', 'Tool that gets action details', method: 'getAction')] +#[AsTool('connery_create_workflow', 'Tool that creates workflows', method: 'createWorkflow')] +#[AsTool('connery_execute_workflow', 'Tool that executes workflows', method: 'executeWorkflow')] +#[AsTool('connery_list_workflows', 'Tool that lists workflows', method: 'listWorkflows')] +#[AsTool('connery_get_workflow', 'Tool that gets workflow details', method: 'getWorkflow')] +#[AsTool('connery_run_recipe', 'Tool that runs recipes', method: 'runRecipe')] +final readonly class Connery +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.connery.io/v1', + private array $options = [], + ) { + } + + /** + * Execute actions using Connery. + * + * @param string $actionId Action ID to execute + * @param array $parameters Action parameters + * @param array $context Execution context + * + * @return array{ + * success: bool, + * execution: array{ + * execution_id: string, + * action_id: string, + * parameters: array, + * status: string, + * result: array, + * started_at: string, + * completed_at: string, + * duration: float, + * logs: array, + * error: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $actionId, + array $parameters = [], + array $context = [], + ): array { + try { + $requestData = [ + 'action_id' => $actionId, + 'parameters' => $parameters, + 'context' => $context, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/actions/{$actionId}/execute", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $execution = $responseData['execution'] ?? []; + + return [ + 'success' => true, + 'execution' => [ + 'execution_id' => $execution['execution_id'] ?? '', + 'action_id' => $actionId, + 'parameters' => $parameters, + 'status' => $execution['status'] ?? 'completed', + 'result' => $execution['result'] ?? [], + 'started_at' => $execution['started_at'] ?? date('c'), + 'completed_at' => $execution['completed_at'] ?? date('c'), + 'duration' => $execution['duration'] ?? 0.0, + 'logs' => array_map(fn ($log) => [ + 'level' => $log['level'] ?? 'info', + 'message' => $log['message'] ?? '', + 'timestamp' => $log['timestamp'] ?? date('c'), + ], $execution['logs'] ?? []), + 'error' => $execution['error'] ?? '', + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'execution' => [ + 'execution_id' => '', + 'action_id' => $actionId, + 'parameters' => $parameters, + 'status' => 'failed', + 'result' => [], + 'started_at' => date('c'), + 'completed_at' => date('c'), + 'duration' => 0.0, + 'logs' => [], + 'error' => $e->getMessage(), + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * List available actions. + * + * @param string $category Action category filter + * @param string $provider Provider filter + * @param int $limit Number of actions to return + * @param int $offset Offset for pagination + * + * @return array{ + * success: bool, + * actions: array, + * output_schema: array, + * tags: array, + * }>, + * total: int, + * limit: int, + * offset: int, + * processingTime: float, + * error: string, + * } + */ + public function listActions( + string $category = '', + string $provider = '', + int $limit = 20, + int $offset = 0, + ): array { + try { + $query = []; + if ($category) { + $query['category'] = $category; + } + if ($provider) { + $query['provider'] = $provider; + } + $query['limit'] = max(1, min($limit, 100)); + $query['offset'] = max(0, $offset); + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/actions", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + ], + 'query' => $query, + ] + $this->options); + + $responseData = $response->toArray(); + $actions = $responseData['actions'] ?? []; + + return [ + 'success' => true, + 'actions' => array_map(fn ($action) => [ + 'id' => $action['id'] ?? '', + 'name' => $action['name'] ?? '', + 'description' => $action['description'] ?? '', + 'category' => $action['category'] ?? '', + 'provider' => $action['provider'] ?? '', + 'parameters' => array_map(fn ($param) => [ + 'name' => $param['name'] ?? '', + 'type' => $param['type'] ?? 'string', + 'required' => $param['required'] ?? false, + 'description' => $param['description'] ?? '', + ], $action['parameters'] ?? []), + 'output_schema' => $action['output_schema'] ?? [], + 'tags' => $action['tags'] ?? [], + ], $actions), + 'total' => $responseData['total'] ?? \count($actions), + 'limit' => $limit, + 'offset' => $offset, + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'actions' => [], + 'total' => 0, + 'limit' => $limit, + 'offset' => $offset, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get action details. + * + * @param string $actionId Action ID + * + * @return array{ + * success: bool, + * action: array{ + * id: string, + * name: string, + * description: string, + * category: string, + * provider: string, + * parameters: array, + * output_schema: array, + * tags: array, + * version: string, + * documentation: string, + * examples: array, + * result: array, + * }>, + * }, + * processingTime: float, + * error: string, + * } + */ + public function getAction(string $actionId): array + { + try { + $response = $this->httpClient->request('GET', "{$this->baseUrl}/actions/{$actionId}", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + ], + ] + $this->options); + + $responseData = $response->toArray(); + $action = $responseData['action'] ?? []; + + return [ + 'success' => true, + 'action' => [ + 'id' => $actionId, + 'name' => $action['name'] ?? '', + 'description' => $action['description'] ?? '', + 'category' => $action['category'] ?? '', + 'provider' => $action['provider'] ?? '', + 'parameters' => array_map(fn ($param) => [ + 'name' => $param['name'] ?? '', + 'type' => $param['type'] ?? 'string', + 'required' => $param['required'] ?? false, + 'description' => $param['description'] ?? '', + 'default_value' => $param['default_value'] ?? null, + ], $action['parameters'] ?? []), + 'output_schema' => $action['output_schema'] ?? [], + 'tags' => $action['tags'] ?? [], + 'version' => $action['version'] ?? '1.0.0', + 'documentation' => $action['documentation'] ?? '', + 'examples' => array_map(fn ($example) => [ + 'name' => $example['name'] ?? '', + 'parameters' => $example['parameters'] ?? [], + 'result' => $example['result'] ?? [], + ], $action['examples'] ?? []), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'action' => [ + 'id' => $actionId, + 'name' => '', + 'description' => '', + 'category' => '', + 'provider' => '', + 'parameters' => [], + 'output_schema' => [], + 'tags' => [], + 'version' => '1.0.0', + 'documentation' => '', + 'examples' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create workflow. + * + * @param string $name Workflow name + * @param string $description Workflow description + * @param array, + * conditions: array, + * }> $steps Workflow steps + * @param array $settings Workflow settings + * + * @return array{ + * success: bool, + * workflow: array{ + * id: string, + * name: string, + * description: string, + * steps: array, + * conditions: array, + * order: int, + * }>, + * settings: array, + * status: string, + * created_at: string, + * updated_at: string, + * version: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function createWorkflow( + string $name, + string $description, + array $steps, + array $settings = [], + ): array { + try { + $requestData = [ + 'name' => $name, + 'description' => $description, + 'steps' => $steps, + 'settings' => $settings, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/workflows", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $workflow = $responseData['workflow'] ?? []; + + return [ + 'success' => true, + 'workflow' => [ + 'id' => $workflow['id'] ?? '', + 'name' => $name, + 'description' => $description, + 'steps' => array_map(fn ($step, $index) => [ + 'step_id' => $step['step_id'] ?? "step_{$index}", + 'action_id' => $step['action_id'], + 'parameters' => $step['parameters'] ?? [], + 'conditions' => $step['conditions'] ?? [], + 'order' => $index + 1, + ], $steps), + 'settings' => $settings, + 'status' => $workflow['status'] ?? 'active', + 'created_at' => $workflow['created_at'] ?? date('c'), + 'updated_at' => $workflow['updated_at'] ?? date('c'), + 'version' => $workflow['version'] ?? '1.0.0', + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'workflow' => [ + 'id' => '', + 'name' => $name, + 'description' => $description, + 'steps' => array_map(fn ($step, $index) => [ + 'step_id' => "step_{$index}", + 'action_id' => $step['action_id'], + 'parameters' => $step['parameters'] ?? [], + 'conditions' => $step['conditions'] ?? [], + 'order' => $index + 1, + ], $steps), + 'settings' => $settings, + 'status' => 'failed', + 'created_at' => date('c'), + 'updated_at' => date('c'), + 'version' => '1.0.0', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Execute workflow. + * + * @param string $workflowId Workflow ID to execute + * @param array $inputs Workflow inputs + * @param array $context Execution context + * + * @return array{ + * success: bool, + * execution: array{ + * execution_id: string, + * workflow_id: string, + * inputs: array, + * status: string, + * results: array, + * started_at: string, + * completed_at: string, + * duration: float, + * }>, + * started_at: string, + * completed_at: string, + * duration: float, + * logs: array, + * error: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function executeWorkflow( + string $workflowId, + array $inputs = [], + array $context = [], + ): array { + try { + $requestData = [ + 'inputs' => $inputs, + 'context' => $context, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/workflows/{$workflowId}/execute", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $execution = $responseData['execution'] ?? []; + + return [ + 'success' => true, + 'execution' => [ + 'execution_id' => $execution['execution_id'] ?? '', + 'workflow_id' => $workflowId, + 'inputs' => $inputs, + 'status' => $execution['status'] ?? 'completed', + 'results' => array_map(fn ($result) => [ + 'step_id' => $result['step_id'] ?? '', + 'status' => $result['status'] ?? 'completed', + 'result' => $result['result'] ?? [], + 'started_at' => $result['started_at'] ?? date('c'), + 'completed_at' => $result['completed_at'] ?? date('c'), + 'duration' => $result['duration'] ?? 0.0, + ], $execution['results'] ?? []), + 'started_at' => $execution['started_at'] ?? date('c'), + 'completed_at' => $execution['completed_at'] ?? date('c'), + 'duration' => $execution['duration'] ?? 0.0, + 'logs' => array_map(fn ($log) => [ + 'level' => $log['level'] ?? 'info', + 'message' => $log['message'] ?? '', + 'timestamp' => $log['timestamp'] ?? date('c'), + ], $execution['logs'] ?? []), + 'error' => $execution['error'] ?? '', + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'execution' => [ + 'execution_id' => '', + 'workflow_id' => $workflowId, + 'inputs' => $inputs, + 'status' => 'failed', + 'results' => [], + 'started_at' => date('c'), + 'completed_at' => date('c'), + 'duration' => 0.0, + 'logs' => [], + 'error' => $e->getMessage(), + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * List workflows. + * + * @param string $status Workflow status filter + * @param int $limit Number of workflows to return + * @param int $offset Offset for pagination + * + * @return array{ + * success: bool, + * workflows: array, + * total: int, + * limit: int, + * offset: int, + * processingTime: float, + * error: string, + * } + */ + public function listWorkflows( + string $status = '', + int $limit = 20, + int $offset = 0, + ): array { + try { + $query = []; + if ($status) { + $query['status'] = $status; + } + $query['limit'] = max(1, min($limit, 100)); + $query['offset'] = max(0, $offset); + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/workflows", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + ], + 'query' => $query, + ] + $this->options); + + $responseData = $response->toArray(); + $workflows = $responseData['workflows'] ?? []; + + return [ + 'success' => true, + 'workflows' => array_map(fn ($workflow) => [ + 'id' => $workflow['id'] ?? '', + 'name' => $workflow['name'] ?? '', + 'description' => $workflow['description'] ?? '', + 'status' => $workflow['status'] ?? 'active', + 'steps_count' => $workflow['steps_count'] ?? 0, + 'created_at' => $workflow['created_at'] ?? '', + 'updated_at' => $workflow['updated_at'] ?? '', + 'version' => $workflow['version'] ?? '1.0.0', + ], $workflows), + 'total' => $responseData['total'] ?? \count($workflows), + 'limit' => $limit, + 'offset' => $offset, + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'workflows' => [], + 'total' => 0, + 'limit' => $limit, + 'offset' => $offset, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get workflow details. + * + * @param string $workflowId Workflow ID + * + * @return array{ + * success: bool, + * workflow: array{ + * id: string, + * name: string, + * description: string, + * steps: array, + * conditions: array, + * order: int, + * }>, + * settings: array, + * status: string, + * created_at: string, + * updated_at: string, + * version: string, + * executions_count: int, + * success_rate: float, + * }, + * processingTime: float, + * error: string, + * } + */ + public function getWorkflow(string $workflowId): array + { + try { + $response = $this->httpClient->request('GET', "{$this->baseUrl}/workflows/{$workflowId}", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + ], + ] + $this->options); + + $responseData = $response->toArray(); + $workflow = $responseData['workflow'] ?? []; + + return [ + 'success' => true, + 'workflow' => [ + 'id' => $workflowId, + 'name' => $workflow['name'] ?? '', + 'description' => $workflow['description'] ?? '', + 'steps' => array_map(fn ($step) => [ + 'step_id' => $step['step_id'] ?? '', + 'action_id' => $step['action_id'] ?? '', + 'parameters' => $step['parameters'] ?? [], + 'conditions' => $step['conditions'] ?? [], + 'order' => $step['order'] ?? 0, + ], $workflow['steps'] ?? []), + 'settings' => $workflow['settings'] ?? [], + 'status' => $workflow['status'] ?? 'active', + 'created_at' => $workflow['created_at'] ?? '', + 'updated_at' => $workflow['updated_at'] ?? '', + 'version' => $workflow['version'] ?? '1.0.0', + 'executions_count' => $workflow['executions_count'] ?? 0, + 'success_rate' => $workflow['success_rate'] ?? 0.0, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'workflow' => [ + 'id' => $workflowId, + 'name' => '', + 'description' => '', + 'steps' => [], + 'settings' => [], + 'status' => 'unknown', + 'created_at' => '', + 'updated_at' => '', + 'version' => '1.0.0', + 'executions_count' => 0, + 'success_rate' => 0.0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Run recipe. + * + * @param string $recipeId Recipe ID to run + * @param array $inputs Recipe inputs + * @param array $context Execution context + * + * @return array{ + * success: bool, + * recipe_execution: array{ + * execution_id: string, + * recipe_id: string, + * inputs: array, + * status: string, + * result: array, + * started_at: string, + * completed_at: string, + * duration: float, + * steps_executed: int, + * steps_total: int, + * logs: array, + * error: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function runRecipe( + string $recipeId, + array $inputs = [], + array $context = [], + ): array { + try { + $requestData = [ + 'inputs' => $inputs, + 'context' => $context, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/recipes/{$recipeId}/run", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $execution = $responseData['recipe_execution'] ?? []; + + return [ + 'success' => true, + 'recipe_execution' => [ + 'execution_id' => $execution['execution_id'] ?? '', + 'recipe_id' => $recipeId, + 'inputs' => $inputs, + 'status' => $execution['status'] ?? 'completed', + 'result' => $execution['result'] ?? [], + 'started_at' => $execution['started_at'] ?? date('c'), + 'completed_at' => $execution['completed_at'] ?? date('c'), + 'duration' => $execution['duration'] ?? 0.0, + 'steps_executed' => $execution['steps_executed'] ?? 0, + 'steps_total' => $execution['steps_total'] ?? 0, + 'logs' => array_map(fn ($log) => [ + 'level' => $log['level'] ?? 'info', + 'message' => $log['message'] ?? '', + 'timestamp' => $log['timestamp'] ?? date('c'), + ], $execution['logs'] ?? []), + 'error' => $execution['error'] ?? '', + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'recipe_execution' => [ + 'execution_id' => '', + 'recipe_id' => $recipeId, + 'inputs' => $inputs, + 'status' => 'failed', + 'result' => [], + 'started_at' => date('c'), + 'completed_at' => date('c'), + 'duration' => 0.0, + 'steps_executed' => 0, + 'steps_total' => 0, + 'logs' => [], + 'error' => $e->getMessage(), + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Consul.php b/src/agent/src/Toolbox/Tool/Consul.php new file mode 100644 index 000000000..a36486df6 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Consul.php @@ -0,0 +1,465 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('consul_get_services', 'Tool that gets Consul services')] +#[AsTool('consul_get_nodes', 'Tool that gets Consul nodes', method: 'getNodes')] +#[AsTool('consul_get_key', 'Tool that gets Consul key-value', method: 'getKey')] +#[AsTool('consul_set_key', 'Tool that sets Consul key-value', method: 'setKey')] +#[AsTool('consul_delete_key', 'Tool that deletes Consul key-value', method: 'deleteKey')] +#[AsTool('consul_get_health', 'Tool that gets Consul health checks', method: 'getHealth')] +final readonly class Consul +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $baseUrl = 'http://localhost:8500', + private string $datacenter = '', + private array $options = [], + ) { + } + + /** + * Get Consul services. + * + * @param string $service Service name filter + * @param string $tag Tag filter + * @param string $near Near node + * @param string $nodeMeta Node metadata filter + * + * @return array, + * nodeMeta: array, + * serviceId: string, + * serviceName: string, + * serviceTags: array, + * serviceAddress: string, + * servicePort: int, + * serviceMeta: array, + * serviceWeights: array{ + * passing: int, + * warning: int, + * }, + * serviceEnableTagOverride: bool, + * createIndex: int, + * modifyIndex: int, + * }> + */ + public function __invoke( + string $service = '', + string $tag = '', + string $near = '', + string $nodeMeta = '', + ): array { + try { + $params = []; + + if ($service) { + $params['filter'] = "Service == \"{$service}\""; + } + if ($tag) { + $params['tag'] = $tag; + } + if ($near) { + $params['near'] = $near; + } + if ($nodeMeta) { + $params['node-meta'] = $nodeMeta; + } + if ($this->datacenter) { + $params['dc'] = $this->datacenter; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/v1/health/service/{$service}", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return array_map(fn ($item) => [ + 'id' => $item['Service']['ID'], + 'node' => $item['Node']['Node'], + 'address' => $item['Node']['Address'], + 'datacenter' => $item['Node']['Datacenter'], + 'taggedAddresses' => $item['Node']['TaggedAddresses'] ?? [], + 'nodeMeta' => $item['Node']['Meta'] ?? [], + 'serviceId' => $item['Service']['ID'], + 'serviceName' => $item['Service']['Service'], + 'serviceTags' => $item['Service']['Tags'] ?? [], + 'serviceAddress' => $item['Service']['Address'] ?? '', + 'servicePort' => $item['Service']['Port'], + 'serviceMeta' => $item['Service']['Meta'] ?? [], + 'serviceWeights' => [ + 'passing' => $item['Service']['Weights']['Passing'], + 'warning' => $item['Service']['Weights']['Warning'], + ], + 'serviceEnableTagOverride' => $item['Service']['EnableTagOverride'] ?? false, + 'createIndex' => $item['Service']['CreateIndex'], + 'modifyIndex' => $item['Service']['ModifyIndex'], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Consul nodes. + * + * @param string $near Near node + * @param string $nodeMeta Node metadata filter + * + * @return array, + * meta: array, + * createIndex: int, + * modifyIndex: int, + * }> + */ + public function getNodes( + string $near = '', + string $nodeMeta = '', + ): array { + try { + $params = []; + + if ($near) { + $params['near'] = $near; + } + if ($nodeMeta) { + $params['node-meta'] = $nodeMeta; + } + if ($this->datacenter) { + $params['dc'] = $this->datacenter; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/v1/catalog/nodes", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return array_map(fn ($node) => [ + 'id' => $node['ID'], + 'node' => $node['Node'], + 'address' => $node['Address'], + 'datacenter' => $node['Datacenter'], + 'taggedAddresses' => $node['TaggedAddresses'] ?? [], + 'meta' => $node['Meta'] ?? [], + 'createIndex' => $node['CreateIndex'], + 'modifyIndex' => $node['ModifyIndex'], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Consul key-value. + * + * @param string $key Key path + * @param string $recurse Recursive query + * @param string $separator Key separator + * + * @return array{ + * key: string, + * value: string, + * flags: int, + * createIndex: int, + * modifyIndex: int, + * lockIndex: int, + * }|array|string + */ + public function getKey( + string $key, + string $recurse = 'false', + string $separator = '', + ): array|string { + try { + $params = []; + + if ('true' === $recurse) { + $params['recurse'] = 'true'; + } + if ($separator) { + $params['separator'] = $separator; + } + if ($this->datacenter) { + $params['dc'] = $this->datacenter; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/v1/kv/{$key}", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if ('true' === $recurse) { + return array_map(fn ($item) => [ + 'key' => $item['Key'], + 'value' => base64_decode($item['Value'] ?? ''), + 'flags' => $item['Flags'], + 'createIndex' => $item['CreateIndex'], + 'modifyIndex' => $item['ModifyIndex'], + 'lockIndex' => $item['LockIndex'], + ], $data); + } + + if (empty($data)) { + return 'Key not found'; + } + + return [ + 'key' => $data[0]['Key'], + 'value' => base64_decode($data[0]['Value'] ?? ''), + 'flags' => $data[0]['Flags'], + 'createIndex' => $data[0]['CreateIndex'], + 'modifyIndex' => $data[0]['ModifyIndex'], + 'lockIndex' => $data[0]['LockIndex'], + ]; + } catch (\Exception $e) { + return 'Error getting key: '.$e->getMessage(); + } + } + + /** + * Set Consul key-value. + * + * @param string $key Key path + * @param string $value Value to set + * @param int $flags Flags + * @param int $cas Check-and-set index + * @param string $acquire Session to acquire + * @param string $release Session to release + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * }|string + */ + public function setKey( + string $key, + string $value, + int $flags = 0, + int $cas = 0, + string $acquire = '', + string $release = '', + ): array|string { + try { + $params = []; + + if ($flags > 0) { + $params['flags'] = $flags; + } + if ($cas > 0) { + $params['cas'] = $cas; + } + if ($acquire) { + $params['acquire'] = $acquire; + } + if ($release) { + $params['release'] = $release; + } + if ($this->datacenter) { + $params['dc'] = $this->datacenter; + } + + $response = $this->httpClient->request('PUT', "{$this->baseUrl}/v1/kv/{$key}", [ + 'query' => array_merge($this->options, $params), + 'body' => $value, + ]); + + $success = 200 === $response->getStatusCode(); + $output = $response->getContent(); + + return [ + 'success' => $success, + 'output' => $output, + 'error' => $success ? '' : 'Failed to set key', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Delete Consul key-value. + * + * @param string $key Key path + * @param string $recurse Recursive delete + * @param int $cas Check-and-set index + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * }|string + */ + public function deleteKey( + string $key, + string $recurse = 'false', + int $cas = 0, + ): array|string { + try { + $params = []; + + if ('true' === $recurse) { + $params['recurse'] = 'true'; + } + if ($cas > 0) { + $params['cas'] = $cas; + } + if ($this->datacenter) { + $params['dc'] = $this->datacenter; + } + + $response = $this->httpClient->request('DELETE', "{$this->baseUrl}/v1/kv/{$key}", [ + 'query' => array_merge($this->options, $params), + ]); + + $success = 200 === $response->getStatusCode(); + $output = $response->getContent(); + + return [ + 'success' => $success, + 'output' => $output, + 'error' => $success ? '' : 'Failed to delete key', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Consul health checks. + * + * @param string $service Service name + * @param string $state Health state filter (passing, warning, critical, maintenance) + * @param string $near Near node + * + * @return array, + * definition: array, + * createIndex: int, + * modifyIndex: int, + * }> + */ + public function getHealth( + string $service = '', + string $state = '', + string $near = '', + ): array { + try { + $params = []; + + if ($state) { + $params['state'] = $state; + } + if ($near) { + $params['near'] = $near; + } + if ($this->datacenter) { + $params['dc'] = $this->datacenter; + } + + $url = $service + ? "{$this->baseUrl}/v1/health/service/{$service}" + : "{$this->baseUrl}/v1/health/state/{$state}"; + + $response = $this->httpClient->request('GET', $url, [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if ($service) { + $checks = []; + foreach ($data as $item) { + foreach ($item['Checks'] ?? [] as $check) { + $checks[] = [ + 'node' => $check['Node'], + 'checkId' => $check['CheckID'], + 'name' => $check['Name'], + 'status' => $check['Status'], + 'notes' => $check['Notes'] ?? '', + 'output' => $check['Output'] ?? '', + 'serviceId' => $check['ServiceID'] ?? '', + 'serviceName' => $check['ServiceName'] ?? '', + 'serviceTags' => $check['ServiceTags'] ?? [], + 'definition' => $check['Definition'] ?? [], + 'createIndex' => $check['CreateIndex'], + 'modifyIndex' => $check['ModifyIndex'], + ]; + } + } + + return $checks; + } + + return array_map(fn ($check) => [ + 'node' => $check['Node'], + 'checkId' => $check['CheckID'], + 'name' => $check['Name'], + 'status' => $check['Status'], + 'notes' => $check['Notes'] ?? '', + 'output' => $check['Output'] ?? '', + 'serviceId' => $check['ServiceID'] ?? '', + 'serviceName' => $check['ServiceName'] ?? '', + 'serviceTags' => $check['ServiceTags'] ?? [], + 'definition' => $check['Definition'] ?? [], + 'createIndex' => $check['CreateIndex'], + 'modifyIndex' => $check['ModifyIndex'], + ], $data); + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/DataForSeo.php b/src/agent/src/Toolbox/Tool/DataForSeo.php new file mode 100644 index 000000000..8dbd90577 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/DataForSeo.php @@ -0,0 +1,975 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('dataforseo_search', 'Tool that performs SEO data analysis using DataForSeo')] +#[AsTool('dataforseo_keyword_analysis', 'Tool that analyzes keywords', method: 'keywordAnalysis')] +#[AsTool('dataforseo_serp_analysis', 'Tool that analyzes SERP data', method: 'serpAnalysis')] +#[AsTool('dataforseo_competitor_analysis', 'Tool that analyzes competitors', method: 'competitorAnalysis')] +#[AsTool('dataforseo_backlink_analysis', 'Tool that analyzes backlinks', method: 'backlinkAnalysis')] +#[AsTool('dataforseo_content_analysis', 'Tool that analyzes content', method: 'contentAnalysis')] +#[AsTool('dataforseo_rank_tracking', 'Tool that tracks rankings', method: 'rankTracking')] +#[AsTool('dataforseo_technical_seo', 'Tool that performs technical SEO analysis', method: 'technicalSeo')] +final readonly class DataForSeo +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.dataforseo.com/v3', + private array $options = [], + ) { + } + + /** + * Perform SEO data analysis using DataForSeo. + * + * @param string $query Search query + * @param string $location Search location + * @param string $language Search language + * @param string $device Device type (desktop, mobile) + * @param string $searchEngine Search engine (google, bing, yahoo) + * + * @return array{ + * success: bool, + * search_data: array{ + * query: string, + * location: string, + * language: string, + * device: string, + * search_engine: string, + * results: array, + * }>, + * ads: array, + * related_searches: array, + * total_results: int, + * search_time: float, + * }, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $query, + string $location = 'United States', + string $language = 'en', + string $device = 'desktop', + string $searchEngine = 'google', + ): array { + try { + $requestData = [ + 'keyword' => $query, + 'location_name' => $location, + 'language_name' => $language, + 'device' => $device, + 'os' => 'windows', + ]; + + $endpoint = 'bing' === $searchEngine ? 'serp/google/organic/live/regular' : 'serp/google/organic/live/regular'; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/{$endpoint}", [ + 'headers' => [ + 'Authorization' => 'Basic '.base64_encode($this->apiKey.':'), + 'Content-Type' => 'application/json', + ], + 'json' => [$requestData], + ] + $this->options); + + $data = $response->toArray(); + $result = $data['tasks'][0]['result'][0] ?? []; + + return [ + 'success' => true, + 'search_data' => [ + 'query' => $query, + 'location' => $location, + 'language' => $language, + 'device' => $device, + 'search_engine' => $searchEngine, + 'results' => array_map(fn ($item, $index) => [ + 'position' => $index + 1, + 'title' => $item['title'] ?? '', + 'url' => $item['url'] ?? '', + 'description' => $item['description'] ?? '', + 'domain' => parse_url($item['url'] ?? '', \PHP_URL_HOST) ?: '', + 'featured_snippet' => $item['featured_snippet'] ?? false, + 'related_searches' => $item['related_searches'] ?? [], + ], $result['items'] ?? []), + 'ads' => array_map(fn ($ad, $index) => [ + 'position' => $index + 1, + 'title' => $ad['title'] ?? '', + 'url' => $ad['url'] ?? '', + 'description' => $ad['description'] ?? '', + 'domain' => parse_url($ad['url'] ?? '', \PHP_URL_HOST) ?: '', + ], $result['ads'] ?? []), + 'related_searches' => $result['related_searches'] ?? [], + 'total_results' => $result['total_results'] ?? 0, + 'search_time' => $result['search_time'] ?? 0.0, + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'search_data' => [ + 'query' => $query, + 'location' => $location, + 'language' => $language, + 'device' => $device, + 'search_engine' => $searchEngine, + 'results' => [], + 'ads' => [], + 'related_searches' => [], + 'total_results' => 0, + 'search_time' => 0.0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze keywords. + * + * @param string $keyword Keyword to analyze + * @param string $location Search location + * @param string $language Search language + * @param array $metrics Metrics to retrieve + * + * @return array{ + * success: bool, + * keyword_analysis: array{ + * keyword: string, + * location: string, + * language: string, + * search_volume: int, + * competition: string, + * competition_level: float, + * cpc: float, + * trends: array, + * related_keywords: array, + * keyword_difficulty: float, + * intent: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function keywordAnalysis( + string $keyword, + string $location = 'United States', + string $language = 'en', + array $metrics = ['search_volume', 'competition', 'cpc', 'trends'], + ): array { + try { + $requestData = [ + 'keyword' => $keyword, + 'location_name' => $location, + 'language_name' => $language, + 'metrics' => $metrics, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/dataforseo_labs/google/keyword_ideas/live", [ + 'headers' => [ + 'Authorization' => 'Basic '.base64_encode($this->apiKey.':'), + 'Content-Type' => 'application/json', + ], + 'json' => [$requestData], + ] + $this->options); + + $data = $response->toArray(); + $result = $data['tasks'][0]['result'][0] ?? []; + + return [ + 'success' => true, + 'keyword_analysis' => [ + 'keyword' => $keyword, + 'location' => $location, + 'language' => $language, + 'search_volume' => $result['search_volume'] ?? 0, + 'competition' => $result['competition'] ?? 'unknown', + 'competition_level' => $result['competition_level'] ?? 0.0, + 'cpc' => $result['cpc'] ?? 0.0, + 'trends' => array_map(fn ($trend) => [ + 'month' => $trend['month'] ?? '', + 'search_volume' => $trend['search_volume'] ?? 0, + ], $result['trends'] ?? []), + 'related_keywords' => array_map(fn ($related) => [ + 'keyword' => $related['keyword'] ?? '', + 'search_volume' => $related['search_volume'] ?? 0, + 'competition' => $related['competition'] ?? 'unknown', + 'cpc' => $related['cpc'] ?? 0.0, + ], $result['related_keywords'] ?? []), + 'keyword_difficulty' => $result['keyword_difficulty'] ?? 0.0, + 'intent' => $result['intent'] ?? 'unknown', + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'keyword_analysis' => [ + 'keyword' => $keyword, + 'location' => $location, + 'language' => $language, + 'search_volume' => 0, + 'competition' => 'unknown', + 'competition_level' => 0.0, + 'cpc' => 0.0, + 'trends' => [], + 'related_keywords' => [], + 'keyword_difficulty' => 0.0, + 'intent' => 'unknown', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze SERP data. + * + * @param string $keyword Keyword to analyze + * @param string $location Search location + * @param string $device Device type + * @param int $depth Analysis depth + * + * @return array{ + * success: bool, + * serp_analysis: array{ + * keyword: string, + * location: string, + * device: string, + * organic_results: array, + * featured_snippets: array, + * people_also_ask: array, + * related_searches: array, + * local_pack: array, + * analysis_summary: array{ + * total_results: int, + * avg_domain_rating: float, + * content_types: array, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function serpAnalysis( + string $keyword, + string $location = 'United States', + string $device = 'desktop', + int $depth = 10, + ): array { + try { + $requestData = [ + 'keyword' => $keyword, + 'location_name' => $location, + 'device' => $device, + 'depth' => $depth, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/dataforseo_labs/google/ranked_keywords/live", [ + 'headers' => [ + 'Authorization' => 'Basic '.base64_encode($this->apiKey.':'), + 'Content-Type' => 'application/json', + ], + 'json' => [$requestData], + ] + $this->options); + + $data = $response->toArray(); + $result = $data['tasks'][0]['result'][0] ?? []; + + return [ + 'success' => true, + 'serp_analysis' => [ + 'keyword' => $keyword, + 'location' => $location, + 'device' => $device, + 'organic_results' => array_map(fn ($item, $index) => [ + 'position' => $index + 1, + 'title' => $item['title'] ?? '', + 'url' => $item['url'] ?? '', + 'description' => $item['description'] ?? '', + 'domain' => parse_url($item['url'] ?? '', \PHP_URL_HOST) ?: '', + 'domain_rating' => $item['domain_rating'] ?? 0.0, + 'page_rating' => $item['page_rating'] ?? 0.0, + 'backlinks' => $item['backlinks'] ?? 0, + ], $result['organic_results'] ?? []), + 'featured_snippets' => array_map(fn ($snippet) => [ + 'title' => $snippet['title'] ?? '', + 'content' => $snippet['content'] ?? '', + 'url' => $snippet['url'] ?? '', + ], $result['featured_snippets'] ?? []), + 'people_also_ask' => $result['people_also_ask'] ?? [], + 'related_searches' => $result['related_searches'] ?? [], + 'local_pack' => array_map(fn ($local) => [ + 'name' => $local['name'] ?? '', + 'address' => $local['address'] ?? '', + 'rating' => $local['rating'] ?? 0.0, + 'reviews' => $local['reviews'] ?? 0, + ], $result['local_pack'] ?? []), + 'analysis_summary' => [ + 'total_results' => \count($result['organic_results'] ?? []), + 'avg_domain_rating' => $result['avg_domain_rating'] ?? 0.0, + 'content_types' => $result['content_types'] ?? [], + ], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'serp_analysis' => [ + 'keyword' => $keyword, + 'location' => $location, + 'device' => $device, + 'organic_results' => [], + 'featured_snippets' => [], + 'people_also_ask' => [], + 'related_searches' => [], + 'local_pack' => [], + 'analysis_summary' => [ + 'total_results' => 0, + 'avg_domain_rating' => 0.0, + 'content_types' => [], + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze competitors. + * + * @param string $domain Domain to analyze + * @param array $analysisTypes Types of analysis to perform + * @param int $limit Number of competitors to analyze + * + * @return array{ + * success: bool, + * competitor_analysis: array{ + * domain: string, + * competitors: array, + * }>, + * market_share: array{ + * total_keywords: int, + * shared_keywords: int, + * unique_keywords: int, + * }, + * analysis_summary: array{ + * top_competitors: array, + * market_opportunities: array, + * competitive_gaps: array, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function competitorAnalysis( + string $domain, + array $analysisTypes = ['keywords', 'backlinks', 'traffic'], + int $limit = 10, + ): array { + try { + $requestData = [ + 'target' => $domain, + 'analysis_types' => $analysisTypes, + 'limit' => max(1, min($limit, 50)), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/dataforseo_labs/google/competitors_domain/live", [ + 'headers' => [ + 'Authorization' => 'Basic '.base64_encode($this->apiKey.':'), + 'Content-Type' => 'application/json', + ], + 'json' => [$requestData], + ] + $this->options); + + $data = $response->toArray(); + $result = $data['tasks'][0]['result'][0] ?? []; + + return [ + 'success' => true, + 'competitor_analysis' => [ + 'domain' => $domain, + 'competitors' => array_map(fn ($competitor) => [ + 'domain' => $competitor['domain'] ?? '', + 'common_keywords' => $competitor['common_keywords'] ?? 0, + 'traffic_share' => $competitor['traffic_share'] ?? 0.0, + 'domain_rating' => $competitor['domain_rating'] ?? 0.0, + 'backlinks' => $competitor['backlinks'] ?? 0, + 'organic_traffic' => $competitor['organic_traffic'] ?? 0, + 'top_keywords' => $competitor['top_keywords'] ?? [], + ], $result['competitors'] ?? []), + 'market_share' => [ + 'total_keywords' => $result['market_share']['total_keywords'] ?? 0, + 'shared_keywords' => $result['market_share']['shared_keywords'] ?? 0, + 'unique_keywords' => $result['market_share']['unique_keywords'] ?? 0, + ], + 'analysis_summary' => [ + 'top_competitors' => $result['analysis_summary']['top_competitors'] ?? [], + 'market_opportunities' => $result['analysis_summary']['market_opportunities'] ?? [], + 'competitive_gaps' => $result['analysis_summary']['competitive_gaps'] ?? [], + ], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'competitor_analysis' => [ + 'domain' => $domain, + 'competitors' => [], + 'market_share' => [ + 'total_keywords' => 0, + 'shared_keywords' => 0, + 'unique_keywords' => 0, + ], + 'analysis_summary' => [ + 'top_competitors' => [], + 'market_opportunities' => [], + 'competitive_gaps' => [], + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze backlinks. + * + * @param string $domain Domain to analyze + * @param array $filters Backlink filters + * @param int $limit Number of backlinks to analyze + * + * @return array{ + * success: bool, + * backlink_analysis: array{ + * domain: string, + * total_backlinks: int, + * referring_domains: int, + * domain_rating: float, + * backlinks: array, + * link_distribution: array{ + * dofollow: int, + * nofollow: int, + * image: int, + * text: int, + * }, + * top_referring_domains: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function backlinkAnalysis( + string $domain, + array $filters = [], + int $limit = 100, + ): array { + try { + $requestData = [ + 'target' => $domain, + 'filters' => $filters, + 'limit' => max(1, min($limit, 1000)), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/backlinks/summary/live", [ + 'headers' => [ + 'Authorization' => 'Basic '.base64_encode($this->apiKey.':'), + 'Content-Type' => 'application/json', + ], + 'json' => [$requestData], + ] + $this->options); + + $data = $response->toArray(); + $result = $data['tasks'][0]['result'][0] ?? []; + + return [ + 'success' => true, + 'backlink_analysis' => [ + 'domain' => $domain, + 'total_backlinks' => $result['total_backlinks'] ?? 0, + 'referring_domains' => $result['referring_domains'] ?? 0, + 'domain_rating' => $result['domain_rating'] ?? 0.0, + 'backlinks' => array_map(fn ($backlink) => [ + 'url' => $backlink['url'] ?? '', + 'domain' => $backlink['domain'] ?? '', + 'anchor_text' => $backlink['anchor_text'] ?? '', + 'link_type' => $backlink['link_type'] ?? '', + 'domain_rating' => $backlink['domain_rating'] ?? 0.0, + 'page_rating' => $backlink['page_rating'] ?? 0.0, + 'first_seen' => $backlink['first_seen'] ?? '', + 'last_seen' => $backlink['last_seen'] ?? '', + ], $result['backlinks'] ?? []), + 'link_distribution' => [ + 'dofollow' => $result['link_distribution']['dofollow'] ?? 0, + 'nofollow' => $result['link_distribution']['nofollow'] ?? 0, + 'image' => $result['link_distribution']['image'] ?? 0, + 'text' => $result['link_distribution']['text'] ?? 0, + ], + 'top_referring_domains' => array_map(fn ($domain) => [ + 'domain' => $domain['domain'] ?? '', + 'backlinks_count' => $domain['backlinks_count'] ?? 0, + 'domain_rating' => $domain['domain_rating'] ?? 0.0, + ], $result['top_referring_domains'] ?? []), + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'backlink_analysis' => [ + 'domain' => $domain, + 'total_backlinks' => 0, + 'referring_domains' => 0, + 'domain_rating' => 0.0, + 'backlinks' => [], + 'link_distribution' => [ + 'dofollow' => 0, + 'nofollow' => 0, + 'image' => 0, + 'text' => 0, + ], + 'top_referring_domains' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze content. + * + * @param string $url URL to analyze + * @param array $analysisTypes Types of content analysis + * + * @return array{ + * success: bool, + * content_analysis: array{ + * url: string, + * title: string, + * meta_description: string, + * word_count: int, + * readability_score: float, + * headings: array{ + * h1: array, + * h2: array, + * h3: array, + * }, + * keywords: array, + * images: array, + * internal_links: int, + * external_links: int, + * seo_score: float, + * recommendations: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function contentAnalysis( + string $url, + array $analysisTypes = ['seo', 'readability', 'keywords', 'images'], + ): array { + try { + $requestData = [ + 'url' => $url, + 'analysis_types' => $analysisTypes, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/on_page/content_analysis", [ + 'headers' => [ + 'Authorization' => 'Basic '.base64_encode($this->apiKey.':'), + 'Content-Type' => 'application/json', + ], + 'json' => [$requestData], + ] + $this->options); + + $data = $response->toArray(); + $result = $data['tasks'][0]['result'][0] ?? []; + + return [ + 'success' => true, + 'content_analysis' => [ + 'url' => $url, + 'title' => $result['title'] ?? '', + 'meta_description' => $result['meta_description'] ?? '', + 'word_count' => $result['word_count'] ?? 0, + 'readability_score' => $result['readability_score'] ?? 0.0, + 'headings' => [ + 'h1' => $result['headings']['h1'] ?? [], + 'h2' => $result['headings']['h2'] ?? [], + 'h3' => $result['headings']['h3'] ?? [], + ], + 'keywords' => array_map(fn ($keyword) => [ + 'keyword' => $keyword['keyword'] ?? '', + 'density' => $keyword['density'] ?? 0.0, + 'position' => $keyword['position'] ?? 0, + ], $result['keywords'] ?? []), + 'images' => array_map(fn ($image) => [ + 'src' => $image['src'] ?? '', + 'alt' => $image['alt'] ?? '', + 'title' => $image['title'] ?? '', + ], $result['images'] ?? []), + 'internal_links' => $result['internal_links'] ?? 0, + 'external_links' => $result['external_links'] ?? 0, + 'seo_score' => $result['seo_score'] ?? 0.0, + 'recommendations' => $result['recommendations'] ?? [], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'content_analysis' => [ + 'url' => $url, + 'title' => '', + 'meta_description' => '', + 'word_count' => 0, + 'readability_score' => 0.0, + 'headings' => [ + 'h1' => [], + 'h2' => [], + 'h3' => [], + ], + 'keywords' => [], + 'images' => [], + 'internal_links' => 0, + 'external_links' => 0, + 'seo_score' => 0.0, + 'recommendations' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Track rankings. + * + * @param string $keyword Keyword to track + * @param string $domain Domain to track + * @param string $location Search location + * @param string $device Device type + * + * @return array{ + * success: bool, + * rank_tracking: array{ + * keyword: string, + * domain: string, + * location: string, + * device: string, + * current_position: int, + * previous_position: int, + * position_change: int, + * url: string, + * tracking_history: array, + * competitors: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function rankTracking( + string $keyword, + string $domain, + string $location = 'United States', + string $device = 'desktop', + ): array { + try { + $requestData = [ + 'keyword' => $keyword, + 'target' => $domain, + 'location_name' => $location, + 'device' => $device, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/dataforseo_labs/google/ranked_keywords/live", [ + 'headers' => [ + 'Authorization' => 'Basic '.base64_encode($this->apiKey.':'), + 'Content-Type' => 'application/json', + ], + 'json' => [$requestData], + ] + $this->options); + + $data = $response->toArray(); + $result = $data['tasks'][0]['result'][0] ?? []; + + return [ + 'success' => true, + 'rank_tracking' => [ + 'keyword' => $keyword, + 'domain' => $domain, + 'location' => $location, + 'device' => $device, + 'current_position' => $result['current_position'] ?? 0, + 'previous_position' => $result['previous_position'] ?? 0, + 'position_change' => ($result['current_position'] ?? 0) - ($result['previous_position'] ?? 0), + 'url' => $result['url'] ?? '', + 'tracking_history' => array_map(fn ($history) => [ + 'date' => $history['date'] ?? '', + 'position' => $history['position'] ?? 0, + 'url' => $history['url'] ?? '', + ], $result['tracking_history'] ?? []), + 'competitors' => array_map(fn ($competitor) => [ + 'domain' => $competitor['domain'] ?? '', + 'position' => $competitor['position'] ?? 0, + 'url' => $competitor['url'] ?? '', + ], $result['competitors'] ?? []), + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'rank_tracking' => [ + 'keyword' => $keyword, + 'domain' => $domain, + 'location' => $location, + 'device' => $device, + 'current_position' => 0, + 'previous_position' => 0, + 'position_change' => 0, + 'url' => '', + 'tracking_history' => [], + 'competitors' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Perform technical SEO analysis. + * + * @param string $url URL to analyze + * @param array $checks Technical checks to perform + * + * @return array{ + * success: bool, + * technical_seo: array{ + * url: string, + * page_speed: array{ + * mobile_score: float, + * desktop_score: float, + * load_time: float, + * recommendations: array, + * }, + * mobile_friendliness: bool, + * structured_data: array{ + * present: bool, + * types: array, + * errors: array, + * }, + * meta_tags: array{ + * title_length: int, + * description_length: int, + * missing_tags: array, + * }, + * technical_issues: array, + * overall_score: float, + * }, + * processingTime: float, + * error: string, + * } + */ + public function technicalSeo( + string $url, + array $checks = ['page_speed', 'mobile_friendliness', 'structured_data', 'meta_tags'], + ): array { + try { + $requestData = [ + 'url' => $url, + 'checks' => $checks, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/on_page/technical_analysis", [ + 'headers' => [ + 'Authorization' => 'Basic '.base64_encode($this->apiKey.':'), + 'Content-Type' => 'application/json', + ], + 'json' => [$requestData], + ] + $this->options); + + $data = $response->toArray(); + $result = $data['tasks'][0]['result'][0] ?? []; + + return [ + 'success' => true, + 'technical_seo' => [ + 'url' => $url, + 'page_speed' => [ + 'mobile_score' => $result['page_speed']['mobile_score'] ?? 0.0, + 'desktop_score' => $result['page_speed']['desktop_score'] ?? 0.0, + 'load_time' => $result['page_speed']['load_time'] ?? 0.0, + 'recommendations' => $result['page_speed']['recommendations'] ?? [], + ], + 'mobile_friendliness' => $result['mobile_friendliness'] ?? false, + 'structured_data' => [ + 'present' => $result['structured_data']['present'] ?? false, + 'types' => $result['structured_data']['types'] ?? [], + 'errors' => $result['structured_data']['errors'] ?? [], + ], + 'meta_tags' => [ + 'title_length' => $result['meta_tags']['title_length'] ?? 0, + 'description_length' => $result['meta_tags']['description_length'] ?? 0, + 'missing_tags' => $result['meta_tags']['missing_tags'] ?? [], + ], + 'technical_issues' => array_map(fn ($issue) => [ + 'type' => $issue['type'] ?? '', + 'severity' => $issue['severity'] ?? 'low', + 'description' => $issue['description'] ?? '', + 'recommendation' => $issue['recommendation'] ?? '', + ], $result['technical_issues'] ?? []), + 'overall_score' => $result['overall_score'] ?? 0.0, + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'technical_seo' => [ + 'url' => $url, + 'page_speed' => [ + 'mobile_score' => 0.0, + 'desktop_score' => 0.0, + 'load_time' => 0.0, + 'recommendations' => [], + ], + 'mobile_friendliness' => false, + 'structured_data' => [ + 'present' => false, + 'types' => [], + 'errors' => [], + ], + 'meta_tags' => [ + 'title_length' => 0, + 'description_length' => 0, + 'missing_tags' => [], + ], + 'technical_issues' => [], + 'overall_score' => 0.0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/DataHerald.php b/src/agent/src/Toolbox/Tool/DataHerald.php new file mode 100644 index 000000000..fcbd9c501 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/DataHerald.php @@ -0,0 +1,824 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('dataherald_query', 'Tool that generates natural language queries using DataHerald')] +#[AsTool('dataherald_sql_generation', 'Tool that generates SQL from natural language', method: 'sqlGeneration')] +#[AsTool('dataherald_schema_analysis', 'Tool that analyzes database schema', method: 'schemaAnalysis')] +#[AsTool('dataherald_query_explanation', 'Tool that explains SQL queries', method: 'queryExplanation')] +#[AsTool('dataherald_data_visualization', 'Tool that generates data visualizations', method: 'dataVisualization')] +#[AsTool('dataherald_nlq_generation', 'Tool that generates natural language questions', method: 'nlqGeneration')] +#[AsTool('dataherald_query_optimization', 'Tool that optimizes queries', method: 'queryOptimization')] +#[AsTool('dataherald_data_storytelling', 'Tool that creates data stories', method: 'dataStorytelling')] +final readonly class DataHerald +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.dataherald.com/v1', + private array $options = [], + ) { + } + + /** + * Generate natural language queries using DataHerald. + * + * @param string $question Natural language question + * @param string $databaseType Database type (mysql, postgresql, sqlite, mssql) + * @param array $context Additional context + * @param string $language Language for response + * + * @return array{ + * success: bool, + * query_generation: array{ + * question: string, + * database_type: string, + * generated_sql: string, + * confidence: float, + * explanation: string, + * parameters: array, + * execution_plan: array, + * alternatives: array, + * }, + * language: string, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $question, + string $databaseType = 'mysql', + array $context = [], + string $language = 'en', + ): array { + try { + $requestData = [ + 'question' => $question, + 'database_type' => $databaseType, + 'context' => $context, + 'language' => $language, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/query/generate", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $result = $data['query_generation'] ?? []; + + return [ + 'success' => true, + 'query_generation' => [ + 'question' => $question, + 'database_type' => $databaseType, + 'generated_sql' => $result['generated_sql'] ?? '', + 'confidence' => $result['confidence'] ?? 0.0, + 'explanation' => $result['explanation'] ?? '', + 'parameters' => $result['parameters'] ?? [], + 'execution_plan' => $result['execution_plan'] ?? [], + 'alternatives' => array_map(fn ($alt) => [ + 'sql' => $alt['sql'] ?? '', + 'confidence' => $alt['confidence'] ?? 0.0, + 'explanation' => $alt['explanation'] ?? '', + ], $result['alternatives'] ?? []), + ], + 'language' => $language, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'query_generation' => [ + 'question' => $question, + 'database_type' => $databaseType, + 'generated_sql' => '', + 'confidence' => 0.0, + 'explanation' => '', + 'parameters' => [], + 'execution_plan' => [], + 'alternatives' => [], + ], + 'language' => $language, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Generate SQL from natural language. + * + * @param string $question Natural language question + * @param string $schema Database schema information + * @param string $databaseType Database type + * @param array $examples Example queries for context + * + * @return array{ + * success: bool, + * sql_generation: array{ + * question: string, + * schema: string, + * database_type: string, + * sql_query: string, + * confidence: float, + * complexity: string, + * estimated_rows: int, + * execution_time: float, + * tables_used: array, + * joins: array, + * condition: string, + * }>, + * validation: array{ + * syntax_valid: bool, + * schema_compatible: bool, + * warnings: array, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function sqlGeneration( + string $question, + string $schema, + string $databaseType = 'mysql', + array $examples = [], + ): array { + try { + $requestData = [ + 'question' => $question, + 'schema' => $schema, + 'database_type' => $databaseType, + 'examples' => $examples, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/sql/generate", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $result = $data['sql_generation'] ?? []; + + return [ + 'success' => true, + 'sql_generation' => [ + 'question' => $question, + 'schema' => $schema, + 'database_type' => $databaseType, + 'sql_query' => $result['sql_query'] ?? '', + 'confidence' => $result['confidence'] ?? 0.0, + 'complexity' => $result['complexity'] ?? 'simple', + 'estimated_rows' => $result['estimated_rows'] ?? 0, + 'execution_time' => $result['execution_time'] ?? 0.0, + 'tables_used' => $result['tables_used'] ?? [], + 'joins' => array_map(fn ($join) => [ + 'type' => $join['type'] ?? '', + 'tables' => $join['tables'] ?? [], + 'condition' => $join['condition'] ?? '', + ], $result['joins'] ?? []), + 'validation' => [ + 'syntax_valid' => $result['validation']['syntax_valid'] ?? false, + 'schema_compatible' => $result['validation']['schema_compatible'] ?? false, + 'warnings' => $result['validation']['warnings'] ?? [], + ], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'sql_generation' => [ + 'question' => $question, + 'schema' => $schema, + 'database_type' => $databaseType, + 'sql_query' => '', + 'confidence' => 0.0, + 'complexity' => 'simple', + 'estimated_rows' => 0, + 'execution_time' => 0.0, + 'tables_used' => [], + 'joins' => [], + 'validation' => [ + 'syntax_valid' => false, + 'schema_compatible' => false, + 'warnings' => [], + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze database schema. + * + * @param string $schema Database schema + * @param string $databaseType Database type + * @param array $analysisOptions Analysis options + * + * @return array{ + * success: bool, + * schema_analysis: array{ + * schema: string, + * database_type: string, + * tables: array, + * indexes: array, + * unique: bool, + * }>, + * relationships: array, + * }>, + * }>, + * relationships: array, + * }>, + * insights: array, + * recommendations: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function schemaAnalysis( + string $schema, + string $databaseType = 'mysql', + array $analysisOptions = [], + ): array { + try { + $requestData = [ + 'schema' => $schema, + 'database_type' => $databaseType, + 'analysis_options' => $analysisOptions, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/schema/analyze", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $result = $data['schema_analysis'] ?? []; + + return [ + 'success' => true, + 'schema_analysis' => [ + 'schema' => $schema, + 'database_type' => $databaseType, + 'tables' => array_map(fn ($table) => [ + 'name' => $table['name'] ?? '', + 'columns' => array_map(fn ($column) => [ + 'name' => $column['name'] ?? '', + 'type' => $column['type'] ?? '', + 'nullable' => $column['nullable'] ?? false, + 'primary_key' => $column['primary_key'] ?? false, + 'foreign_key' => $column['foreign_key'] ?? null, + ], $table['columns'] ?? []), + 'indexes' => array_map(fn ($index) => [ + 'name' => $index['name'] ?? '', + 'columns' => $index['columns'] ?? [], + 'unique' => $index['unique'] ?? false, + ], $table['indexes'] ?? []), + 'relationships' => array_map(fn ($rel) => [ + 'type' => $rel['type'] ?? '', + 'target_table' => $rel['target_table'] ?? '', + 'columns' => $rel['columns'] ?? [], + ], $table['relationships'] ?? []), + ], $result['tables'] ?? []), + 'relationships' => array_map(fn ($rel) => [ + 'from_table' => $rel['from_table'] ?? '', + 'to_table' => $rel['to_table'] ?? '', + 'type' => $rel['type'] ?? '', + 'columns' => $rel['columns'] ?? [], + ], $result['relationships'] ?? []), + 'insights' => $result['insights'] ?? [], + 'recommendations' => $result['recommendations'] ?? [], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'schema_analysis' => [ + 'schema' => $schema, + 'database_type' => $databaseType, + 'tables' => [], + 'relationships' => [], + 'insights' => [], + 'recommendations' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Explain SQL queries. + * + * @param string $sqlQuery SQL query to explain + * @param string $databaseType Database type + * @param string $explanationType Type of explanation + * + * @return array{ + * success: bool, + * query_explanation: array{ + * sql_query: string, + * database_type: string, + * explanation_type: string, + * explanation: string, + * purpose: string, + * steps: array, + * }>, + * complexity: string, + * performance_notes: array, + * business_impact: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function queryExplanation( + string $sqlQuery, + string $databaseType = 'mysql', + string $explanationType = 'detailed', + ): array { + try { + $requestData = [ + 'sql_query' => $sqlQuery, + 'database_type' => $databaseType, + 'explanation_type' => $explanationType, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/query/explain", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $result = $data['query_explanation'] ?? []; + + return [ + 'success' => true, + 'query_explanation' => [ + 'sql_query' => $sqlQuery, + 'database_type' => $databaseType, + 'explanation_type' => $explanationType, + 'explanation' => $result['explanation'] ?? '', + 'purpose' => $result['purpose'] ?? '', + 'steps' => array_map(fn ($step) => [ + 'step' => $step['step'] ?? 0, + 'operation' => $step['operation'] ?? '', + 'description' => $step['description'] ?? '', + 'tables_involved' => $step['tables_involved'] ?? [], + ], $result['steps'] ?? []), + 'complexity' => $result['complexity'] ?? 'simple', + 'performance_notes' => $result['performance_notes'] ?? [], + 'business_impact' => $result['business_impact'] ?? '', + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'query_explanation' => [ + 'sql_query' => $sqlQuery, + 'database_type' => $databaseType, + 'explanation_type' => $explanationType, + 'explanation' => '', + 'purpose' => '', + 'steps' => [], + 'complexity' => 'simple', + 'performance_notes' => [], + 'business_impact' => '', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Generate data visualizations. + * + * @param array> $data Data to visualize + * @param string $visualizationType Type of visualization + * @param array $options Visualization options + * + * @return array{ + * success: bool, + * data_visualization: array{ + * data: array>, + * visualization_type: string, + * chart_config: array, + * insights: array, + * recommendations: array, + * chart_url: string, + * embed_code: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function dataVisualization( + array $data, + string $visualizationType = 'bar', + array $options = [], + ): array { + try { + $requestData = [ + 'data' => $data, + 'visualization_type' => $visualizationType, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/visualization/generate", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $result = $data['data_visualization'] ?? []; + + return [ + 'success' => true, + 'data_visualization' => [ + 'data' => $data, + 'visualization_type' => $visualizationType, + 'chart_config' => $result['chart_config'] ?? [], + 'insights' => $result['insights'] ?? [], + 'recommendations' => $result['recommendations'] ?? [], + 'chart_url' => $result['chart_url'] ?? '', + 'embed_code' => $result['embed_code'] ?? '', + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'data_visualization' => [ + 'data' => $data, + 'visualization_type' => $visualizationType, + 'chart_config' => [], + 'insights' => [], + 'recommendations' => [], + 'chart_url' => '', + 'embed_code' => '', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Generate natural language questions. + * + * @param string $schema Database schema + * @param string $databaseType Database type + * @param array $context Context for question generation + * + * @return array{ + * success: bool, + * nlq_generation: array{ + * schema: string, + * database_type: string, + * questions: array, + * categories: array, + * difficulty_distribution: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function nlqGeneration( + string $schema, + string $databaseType = 'mysql', + array $context = [], + ): array { + try { + $requestData = [ + 'schema' => $schema, + 'database_type' => $databaseType, + 'context' => $context, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/nlq/generate", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $result = $data['nlq_generation'] ?? []; + + return [ + 'success' => true, + 'nlq_generation' => [ + 'schema' => $schema, + 'database_type' => $databaseType, + 'questions' => array_map(fn ($question) => [ + 'question' => $question['question'] ?? '', + 'complexity' => $question['complexity'] ?? 'simple', + 'category' => $question['category'] ?? '', + 'sql_example' => $question['sql_example'] ?? '', + 'business_value' => $question['business_value'] ?? '', + ], $result['questions'] ?? []), + 'categories' => $result['categories'] ?? [], + 'difficulty_distribution' => $result['difficulty_distribution'] ?? [], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'nlq_generation' => [ + 'schema' => $schema, + 'database_type' => $databaseType, + 'questions' => [], + 'categories' => [], + 'difficulty_distribution' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Optimize queries. + * + * @param string $sqlQuery SQL query to optimize + * @param string $databaseType Database type + * @param array $optimizationOptions Optimization options + * + * @return array{ + * success: bool, + * query_optimization: array{ + * original_query: string, + * optimized_query: string, + * database_type: string, + * improvements: array, + * performance_metrics: array{ + * original_time: float, + * optimized_time: float, + * improvement_percentage: float, + * }, + * recommendations: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function queryOptimization( + string $sqlQuery, + string $databaseType = 'mysql', + array $optimizationOptions = [], + ): array { + try { + $requestData = [ + 'sql_query' => $sqlQuery, + 'database_type' => $databaseType, + 'optimization_options' => $optimizationOptions, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/query/optimize", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $result = $data['query_optimization'] ?? []; + + return [ + 'success' => true, + 'query_optimization' => [ + 'original_query' => $sqlQuery, + 'optimized_query' => $result['optimized_query'] ?? '', + 'database_type' => $databaseType, + 'improvements' => array_map(fn ($improvement) => [ + 'type' => $improvement['type'] ?? '', + 'description' => $improvement['description'] ?? '', + 'impact' => $improvement['impact'] ?? '', + 'before' => $improvement['before'] ?? '', + 'after' => $improvement['after'] ?? '', + ], $result['improvements'] ?? []), + 'performance_metrics' => [ + 'original_time' => $result['performance_metrics']['original_time'] ?? 0.0, + 'optimized_time' => $result['performance_metrics']['optimized_time'] ?? 0.0, + 'improvement_percentage' => $result['performance_metrics']['improvement_percentage'] ?? 0.0, + ], + 'recommendations' => $result['recommendations'] ?? [], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'query_optimization' => [ + 'original_query' => $sqlQuery, + 'optimized_query' => '', + 'database_type' => $databaseType, + 'improvements' => [], + 'performance_metrics' => [ + 'original_time' => 0.0, + 'optimized_time' => 0.0, + 'improvement_percentage' => 0.0, + ], + 'recommendations' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create data stories. + * + * @param array> $data Data for storytelling + * @param string $storyType Type of story to create + * @param array $options Story options + * + * @return array{ + * success: bool, + * data_storytelling: array{ + * data: array>, + * story_type: string, + * story: array{ + * title: string, + * narrative: string, + * key_insights: array, + * visualizations: array, + * recommendations: array, + * conclusion: string, + * }, + * story_url: string, + * embed_code: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function dataStorytelling( + array $data, + string $storyType = 'analytical', + array $options = [], + ): array { + try { + $requestData = [ + 'data' => $data, + 'story_type' => $storyType, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/storytelling/create", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $result = $responseData['data_storytelling'] ?? []; + + return [ + 'success' => true, + 'data_storytelling' => [ + 'data' => $data, + 'story_type' => $storyType, + 'story' => [ + 'title' => $result['story']['title'] ?? '', + 'narrative' => $result['story']['narrative'] ?? '', + 'key_insights' => $result['story']['key_insights'] ?? [], + 'visualizations' => array_map(fn ($viz) => [ + 'type' => $viz['type'] ?? '', + 'title' => $viz['title'] ?? '', + 'description' => $viz['description'] ?? '', + 'chart_url' => $viz['chart_url'] ?? '', + ], $result['story']['visualizations'] ?? []), + 'recommendations' => $result['story']['recommendations'] ?? [], + 'conclusion' => $result['story']['conclusion'] ?? '', + ], + 'story_url' => $result['story_url'] ?? '', + 'embed_code' => $result['embed_code'] ?? '', + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'data_storytelling' => [ + 'data' => $data, + 'story_type' => $storyType, + 'story' => [ + 'title' => '', + 'narrative' => '', + 'key_insights' => [], + 'visualizations' => [], + 'recommendations' => [], + 'conclusion' => '', + ], + 'story_url' => '', + 'embed_code' => '', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Databricks.php b/src/agent/src/Toolbox/Tool/Databricks.php new file mode 100644 index 000000000..0db17126e --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Databricks.php @@ -0,0 +1,730 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('databricks_execute_notebook', 'Tool that executes Databricks notebooks')] +#[AsTool('databricks_create_cluster', 'Tool that creates Databricks clusters', method: 'createCluster')] +#[AsTool('databricks_list_clusters', 'Tool that lists Databricks clusters', method: 'listClusters')] +#[AsTool('databricks_get_cluster', 'Tool that gets Databricks cluster details', method: 'getCluster')] +#[AsTool('databricks_terminate_cluster', 'Tool that terminates Databricks clusters', method: 'terminateCluster')] +#[AsTool('databricks_upload_file', 'Tool that uploads files to Databricks', method: 'uploadFile')] +#[AsTool('databricks_list_jobs', 'Tool that lists Databricks jobs', method: 'listJobs')] +#[AsTool('databricks_run_job', 'Tool that runs Databricks jobs', method: 'runJob')] +final readonly class Databricks +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $workspaceUrl, + private string $accessToken, + private array $options = [], + ) { + } + + /** + * Execute Databricks notebook. + * + * @param string $notebookPath Path to the notebook + * @param string $clusterId Cluster ID to run on + * @param array $parameters Notebook parameters + * @param string $language Notebook language (python, scala, sql, r) + * + * @return array{ + * success: bool, + * runId: string, + * notebookPath: string, + * clusterId: string, + * state: string, + * startTime: int, + * setupDuration: int, + * executionDuration: int, + * cleanupDuration: int, + * result: string, + * error: string, + * } + */ + public function __invoke( + string $notebookPath, + string $clusterId, + array $parameters = [], + string $language = 'python', + ): array { + try { + $requestData = [ + 'notebook_path' => $notebookPath, + 'cluster_id' => $clusterId, + 'notebook_params' => $parameters, + 'language' => $language, + ]; + + $response = $this->httpClient->request('POST', "{$this->workspaceUrl}/api/2.0/jobs/runs/submit", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'runId' => $data['run_id'] ?? '', + 'notebookPath' => $notebookPath, + 'clusterId' => $clusterId, + 'state' => $data['state'] ?? 'PENDING', + 'startTime' => $data['start_time'] ?? 0, + 'setupDuration' => $data['setup_duration'] ?? 0, + 'executionDuration' => $data['execution_duration'] ?? 0, + 'cleanupDuration' => $data['cleanup_duration'] ?? 0, + 'result' => $data['result'] ?? '', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'runId' => '', + 'notebookPath' => $notebookPath, + 'clusterId' => $clusterId, + 'state' => 'ERROR', + 'startTime' => 0, + 'setupDuration' => 0, + 'executionDuration' => 0, + 'cleanupDuration' => 0, + 'result' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create Databricks cluster. + * + * @param string $clusterName Cluster name + * @param string $sparkVersion Spark version + * @param string $nodeTypeId Node type ID + * @param int $numWorkers Number of worker nodes + * @param string $driverNodeTypeId Driver node type ID + * @param array $sparkConf Spark configuration + * @param array $awsAttributes AWS attributes + * @param array $autoterminationMinutes Auto-termination minutes + * + * @return array{ + * success: bool, + * clusterId: string, + * clusterName: string, + * state: string, + * sparkVersion: string, + * nodeTypeId: string, + * driverNodeTypeId: string, + * numWorkers: int, + * autoterminationMinutes: int, + * startTime: int, + * error: string, + * } + */ + public function createCluster( + string $clusterName, + string $sparkVersion = '13.3.x-scala2.12', + string $nodeTypeId = 'i3.xlarge', + int $numWorkers = 1, + string $driverNodeTypeId = '', + array $sparkConf = [], + array $awsAttributes = [], + int $autoterminationMinutes = 30, + ): array { + try { + $requestData = [ + 'cluster_name' => $clusterName, + 'spark_version' => $sparkVersion, + 'node_type_id' => $nodeTypeId, + 'num_workers' => $numWorkers, + 'spark_conf' => $sparkConf, + 'aws_attributes' => $awsAttributes, + 'autotermination_minutes' => $autoterminationMinutes, + ]; + + if ($driverNodeTypeId) { + $requestData['driver_node_type_id'] = $driverNodeTypeId; + } + + $response = $this->httpClient->request('POST', "{$this->workspaceUrl}/api/2.0/clusters/create", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'clusterId' => $data['cluster_id'] ?? '', + 'clusterName' => $clusterName, + 'state' => $data['state'] ?? 'PENDING', + 'sparkVersion' => $sparkVersion, + 'nodeTypeId' => $nodeTypeId, + 'driverNodeTypeId' => $driverNodeTypeId ?: $nodeTypeId, + 'numWorkers' => $numWorkers, + 'autoterminationMinutes' => $autoterminationMinutes, + 'startTime' => $data['start_time'] ?? 0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'clusterId' => '', + 'clusterName' => $clusterName, + 'state' => 'ERROR', + 'sparkVersion' => $sparkVersion, + 'nodeTypeId' => $nodeTypeId, + 'driverNodeTypeId' => $driverNodeTypeId, + 'numWorkers' => $numWorkers, + 'autoterminationMinutes' => $autoterminationMinutes, + 'startTime' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * List Databricks clusters. + * + * @param bool $canUseClient Include clusters that can be used by client + * + * @return array{ + * success: bool, + * clusters: array, + * clusterLogConf: array, + * initScripts: array>, + * sparkConf: array, + * awsAttributes: array, + * azureAttributes: array, + * gcpAttributes: array, + * customTags: array, + * enableElasticDisk: bool, + * driver: array, + * executors: array>, + * sparkContextId: int, + * jdbcPort: int, + * sparkUiPort: int, + * sparkVersion: string, + * stateMessage: string, + * startTime: int, + * terminatedTime: int, + * lastStateLossTime: int, + * lastActivityTime: int, + * clusterMemoryMb: int, + * clusterCores: float, + * defaultTags: array, + * clusterLogConf: array, + * initScripts: array>, + * sparkConf: array, + * awsAttributes: array, + * azureAttributes: array, + * gcpAttributes: array, + * customTags: array, + * enableElasticDisk: bool, + * driver: array, + * executors: array>, + * sparkContextId: int, + * jdbcPort: int, + * sparkUiPort: int, + * sparkVersion: string, + * stateMessage: string, + * }>, + * error: string, + * } + */ + public function listClusters(bool $canUseClient = false): array + { + try { + $params = []; + if ($canUseClient) { + $params['can_use_client'] = 'true'; + } + + $response = $this->httpClient->request('GET', "{$this->workspaceUrl}/api/2.0/clusters/list", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'clusters' => array_map(fn ($cluster) => [ + 'clusterId' => $cluster['cluster_id'], + 'clusterName' => $cluster['cluster_name'], + 'state' => $cluster['state'], + 'sparkVersion' => $cluster['spark_version'], + 'nodeTypeId' => $cluster['node_type_id'], + 'driverNodeTypeId' => $cluster['driver_node_type_id'], + 'numWorkers' => $cluster['num_workers'], + 'autoterminationMinutes' => $cluster['autotermination_minutes'], + 'startTime' => $cluster['start_time'] ?? 0, + 'terminatedTime' => $cluster['terminated_time'] ?? 0, + 'lastStateLossTime' => $cluster['last_state_loss_time'] ?? 0, + 'lastActivityTime' => $cluster['last_activity_time'] ?? 0, + 'clusterMemoryMb' => $cluster['cluster_memory_mb'] ?? 0, + 'clusterCores' => $cluster['cluster_cores'] ?? 0.0, + 'defaultTags' => $cluster['default_tags'] ?? [], + 'clusterLogConf' => $cluster['cluster_log_conf'] ?? [], + 'initScripts' => $cluster['init_scripts'] ?? [], + 'sparkConf' => $cluster['spark_conf'] ?? [], + 'awsAttributes' => $cluster['aws_attributes'] ?? [], + 'azureAttributes' => $cluster['azure_attributes'] ?? [], + 'gcpAttributes' => $cluster['gcp_attributes'] ?? [], + 'customTags' => $cluster['custom_tags'] ?? [], + 'enableElasticDisk' => $cluster['enable_elastic_disk'] ?? false, + 'driver' => $cluster['driver'] ?? [], + 'executors' => $cluster['executors'] ?? [], + 'sparkContextId' => $cluster['spark_context_id'] ?? 0, + 'jdbcPort' => $cluster['jdbc_port'] ?? 0, + 'sparkUiPort' => $cluster['spark_ui_port'] ?? 0, + 'sparkVersion' => $cluster['spark_version'], + 'stateMessage' => $cluster['state_message'] ?? '', + ], $data['clusters'] ?? []), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'clusters' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Databricks cluster details. + * + * @param string $clusterId Cluster ID + * + * @return array{ + * success: bool, + * cluster: array{ + * clusterId: string, + * clusterName: string, + * state: string, + * sparkVersion: string, + * nodeTypeId: string, + * driverNodeTypeId: string, + * numWorkers: int, + * autoterminationMinutes: int, + * startTime: int, + * terminatedTime: int, + * lastStateLossTime: int, + * lastActivityTime: int, + * clusterMemoryMb: int, + * clusterCores: float, + * defaultTags: array, + * clusterLogConf: array, + * initScripts: array>, + * sparkConf: array, + * awsAttributes: array, + * azureAttributes: array, + * gcpAttributes: array, + * customTags: array, + * enableElasticDisk: bool, + * driver: array, + * executors: array>, + * sparkContextId: int, + * jdbcPort: int, + * sparkUiPort: int, + * sparkVersion: string, + * stateMessage: string, + * }, + * error: string, + * } + */ + public function getCluster(string $clusterId): array + { + try { + $response = $this->httpClient->request('GET', "{$this->workspaceUrl}/api/2.0/clusters/get", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, ['cluster_id' => $clusterId]), + ]); + + $data = $response->toArray(); + $cluster = $data; + + return [ + 'success' => true, + 'cluster' => [ + 'clusterId' => $cluster['cluster_id'], + 'clusterName' => $cluster['cluster_name'], + 'state' => $cluster['state'], + 'sparkVersion' => $cluster['spark_version'], + 'nodeTypeId' => $cluster['node_type_id'], + 'driverNodeTypeId' => $cluster['driver_node_type_id'], + 'numWorkers' => $cluster['num_workers'], + 'autoterminationMinutes' => $cluster['autotermination_minutes'], + 'startTime' => $cluster['start_time'] ?? 0, + 'terminatedTime' => $cluster['terminated_time'] ?? 0, + 'lastStateLossTime' => $cluster['last_state_loss_time'] ?? 0, + 'lastActivityTime' => $cluster['last_activity_time'] ?? 0, + 'clusterMemoryMb' => $cluster['cluster_memory_mb'] ?? 0, + 'clusterCores' => $cluster['cluster_cores'] ?? 0.0, + 'defaultTags' => $cluster['default_tags'] ?? [], + 'clusterLogConf' => $cluster['cluster_log_conf'] ?? [], + 'initScripts' => $cluster['init_scripts'] ?? [], + 'sparkConf' => $cluster['spark_conf'] ?? [], + 'awsAttributes' => $cluster['aws_attributes'] ?? [], + 'azureAttributes' => $cluster['azure_attributes'] ?? [], + 'gcpAttributes' => $cluster['gcp_attributes'] ?? [], + 'customTags' => $cluster['custom_tags'] ?? [], + 'enableElasticDisk' => $cluster['enable_elastic_disk'] ?? false, + 'driver' => $cluster['driver'] ?? [], + 'executors' => $cluster['executors'] ?? [], + 'sparkContextId' => $cluster['spark_context_id'] ?? 0, + 'jdbcPort' => $cluster['jdbc_port'] ?? 0, + 'sparkUiPort' => $cluster['spark_ui_port'] ?? 0, + 'sparkVersion' => $cluster['spark_version'], + 'stateMessage' => $cluster['state_message'] ?? '', + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'cluster' => [ + 'clusterId' => $clusterId, + 'clusterName' => '', + 'state' => 'ERROR', + 'sparkVersion' => '', + 'nodeTypeId' => '', + 'driverNodeTypeId' => '', + 'numWorkers' => 0, + 'autoterminationMinutes' => 0, + 'startTime' => 0, + 'terminatedTime' => 0, + 'lastStateLossTime' => 0, + 'lastActivityTime' => 0, + 'clusterMemoryMb' => 0, + 'clusterCores' => 0.0, + 'defaultTags' => [], + 'clusterLogConf' => [], + 'initScripts' => [], + 'sparkConf' => [], + 'awsAttributes' => [], + 'azureAttributes' => [], + 'gcpAttributes' => [], + 'customTags' => [], + 'enableElasticDisk' => false, + 'driver' => [], + 'executors' => [], + 'sparkContextId' => 0, + 'jdbcPort' => 0, + 'sparkUiPort' => 0, + 'sparkVersion' => '', + 'stateMessage' => '', + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Terminate Databricks cluster. + * + * @param string $clusterId Cluster ID + * + * @return array{ + * success: bool, + * clusterId: string, + * state: string, + * message: string, + * error: string, + * } + */ + public function terminateCluster(string $clusterId): array + { + try { + $requestData = [ + 'cluster_id' => $clusterId, + ]; + + $response = $this->httpClient->request('POST', "{$this->workspaceUrl}/api/2.0/clusters/delete", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'clusterId' => $clusterId, + 'state' => 'TERMINATING', + 'message' => 'Cluster termination initiated', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'clusterId' => $clusterId, + 'state' => 'ERROR', + 'message' => 'Failed to terminate cluster', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Upload file to Databricks. + * + * @param string $filePath Local file path + * @param string $destination Destination path in Databricks + * @param bool $overwrite Overwrite existing file + * + * @return array{ + * success: bool, + * filePath: string, + * destination: string, + * fileSize: int, + * message: string, + * error: string, + * } + */ + public function uploadFile( + string $filePath, + string $destination, + bool $overwrite = false, + ): array { + try { + if (!file_exists($filePath)) { + throw new \InvalidArgumentException("File not found: {$filePath}."); + } + + $fileContent = file_get_contents($filePath); + $fileName = basename($filePath); + + $requestData = [ + 'path' => $destination, + 'contents' => base64_encode($fileContent), + 'overwrite' => $overwrite, + ]; + + $response = $this->httpClient->request('POST', "{$this->workspaceUrl}/api/2.0/dbfs/put", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'filePath' => $filePath, + 'destination' => $destination, + 'fileSize' => \strlen($fileContent), + 'message' => 'File uploaded successfully', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'filePath' => $filePath, + 'destination' => $destination, + 'fileSize' => 0, + 'message' => 'Failed to upload file', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * List Databricks jobs. + * + * @param int $limit Number of jobs to return + * @param int $offset Offset for pagination + * @param string $expandTasks Expand task details + * + * @return array{ + * success: bool, + * jobs: array, + * schedule: array, + * maxConcurrentRuns: int, + * maxConcurrentRuns: int, + * tags: array, + * tasks: array>, + * }>, + * hasMore: bool, + * error: string, + * } + */ + public function listJobs( + int $limit = 25, + int $offset = 0, + string $expandTasks = 'false', + ): array { + try { + $params = [ + 'limit' => max(1, min($limit, 100)), + 'offset' => max(0, $offset), + 'expand_tasks' => $expandTasks, + ]; + + $response = $this->httpClient->request('GET', "{$this->workspaceUrl}/api/2.0/jobs/list", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'jobs' => array_map(fn ($job) => [ + 'jobId' => $job['job_id'], + 'name' => $job['name'], + 'creatorUserName' => $job['creator_user_name'], + 'runAsUserName' => $job['run_as_user_name'], + 'createdTime' => $job['created_time'], + 'settings' => $job['settings'] ?? [], + 'schedule' => $job['schedule'] ?? [], + 'maxConcurrentRuns' => $job['max_concurrent_runs'] ?? 1, + 'tags' => $job['tags'] ?? [], + 'tasks' => $job['tasks'] ?? [], + ], $data['jobs'] ?? []), + 'hasMore' => $data['has_more'] ?? false, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'jobs' => [], + 'hasMore' => false, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Run Databricks job. + * + * @param int $jobId Job ID + * @param array $notebookParams Notebook parameters + * @param array $pythonParams Python parameters + * @param array $jarParams JAR parameters + * @param array $sparkSubmitParams Spark submit parameters + * + * @return array{ + * success: bool, + * runId: int, + * jobId: int, + * state: string, + * startTime: int, + * setupDuration: int, + * executionDuration: int, + * cleanupDuration: int, + * result: string, + * error: string, + * } + */ + public function runJob( + int $jobId, + array $notebookParams = [], + array $pythonParams = [], + array $jarParams = [], + array $sparkSubmitParams = [], + ): array { + try { + $requestData = [ + 'job_id' => $jobId, + 'notebook_params' => $notebookParams, + 'python_params' => $pythonParams, + 'jar_params' => $jarParams, + 'spark_submit_params' => $sparkSubmitParams, + ]; + + $response = $this->httpClient->request('POST', "{$this->workspaceUrl}/api/2.0/jobs/run-now", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'runId' => $data['run_id'] ?? 0, + 'jobId' => $jobId, + 'state' => $data['state'] ?? 'PENDING', + 'startTime' => $data['start_time'] ?? 0, + 'setupDuration' => $data['setup_duration'] ?? 0, + 'executionDuration' => $data['execution_duration'] ?? 0, + 'cleanupDuration' => $data['cleanup_duration'] ?? 0, + 'result' => $data['result'] ?? '', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'runId' => 0, + 'jobId' => $jobId, + 'state' => 'ERROR', + 'startTime' => 0, + 'setupDuration' => 0, + 'executionDuration' => 0, + 'cleanupDuration' => 0, + 'result' => '', + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Datadog.php b/src/agent/src/Toolbox/Tool/Datadog.php new file mode 100644 index 000000000..0f780d2fd --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Datadog.php @@ -0,0 +1,666 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('datadog_get_dashboards', 'Tool that gets Datadog dashboards')] +#[AsTool('datadog_get_metrics', 'Tool that gets Datadog metrics', method: 'getMetrics')] +#[AsTool('datadog_get_logs', 'Tool that gets Datadog logs', method: 'getLogs')] +#[AsTool('datadog_get_alerts', 'Tool that gets Datadog alerts', method: 'getAlerts')] +#[AsTool('datadog_get_monitors', 'Tool that gets Datadog monitors', method: 'getMonitors')] +#[AsTool('datadog_get_events', 'Tool that gets Datadog events', method: 'getEvents')] +final readonly class Datadog +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + #[\SensitiveParameter] private string $applicationKey, + private string $site = 'datadoghq.com', + private array $options = [], + ) { + } + + /** + * Get Datadog dashboards. + * + * @param string $query Search query + * @param int $perPage Number of dashboards per page + * @param int $page Page number + * @param string $order Order by field (created, modified, name) + * @param string $direction Order direction (asc, desc) + * + * @return array, + * created_at: string, + * modified_at: string, + * layout_type: string, + * widgets: array, + * layout: array{x: int, y: int, width: int, height: int}, + * }>, + * notify_list: array, + * template_variables: array, + * default: string, + * }>, + * }> + */ + public function __invoke( + string $query = '', + int $perPage = 50, + int $page = 0, + string $order = 'created', + string $direction = 'desc', + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 0), + 'order' => $order, + 'direction' => $direction, + ]; + + if ($query) { + $params['query'] = $query; + } + + $response = $this->httpClient->request('GET', "https://api.{$this->site}/api/v1/dashboard", [ + 'headers' => [ + 'DD-API-KEY' => $this->apiKey, + 'DD-APPLICATION-KEY' => $this->applicationKey, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return []; + } + + return array_map(fn ($dashboard) => [ + 'id' => $dashboard['id'], + 'title' => $dashboard['title'], + 'description' => $dashboard['description'], + 'author_handle' => $dashboard['author_handle'], + 'author_name' => $dashboard['author_name'], + 'url' => $dashboard['url'], + 'is_read_only' => $dashboard['is_read_only'] ?? false, + 'is_favorite' => $dashboard['is_favorite'] ?? false, + 'is_shared' => $dashboard['is_shared'] ?? false, + 'tags' => $dashboard['tags'] ?? [], + 'created_at' => $dashboard['created_at'], + 'modified_at' => $dashboard['modified_at'], + 'layout_type' => $dashboard['layout_type'], + 'widgets' => array_map(fn ($widget) => [ + 'id' => $widget['id'], + 'definition' => $widget['definition'], + 'layout' => [ + 'x' => $widget['layout']['x'], + 'y' => $widget['layout']['y'], + 'width' => $widget['layout']['width'], + 'height' => $widget['layout']['height'], + ], + ], $dashboard['widgets'] ?? []), + 'notify_list' => $dashboard['notify_list'] ?? [], + 'template_variables' => array_map(fn ($variable) => [ + 'name' => $variable['name'], + 'prefix' => $variable['prefix'], + 'available_values' => $variable['available_values'] ?? [], + 'default' => $variable['default'] ?? '', + ], $dashboard['template_variables'] ?? []), + ], $data['dashboards'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Datadog metrics. + * + * @param string $from Start time (ISO 8601 or Unix timestamp) + * @param string $to End time (ISO 8601 or Unix timestamp) + * @param string $query Metric query + * @param string $aggregation Aggregation method (avg, sum, min, max, count) + * @param string $interval Time interval (1m, 5m, 10m, 1h, etc.) + * + * @return array{ + * series: array, + * scope: string, + * tag_set: array, + * }>, + * from_date: int, + * to_date: int, + * query: string, + * message: string, + * res_type: string, + * resp_version: int, + * status: string, + * }|string + */ + public function getMetrics( + string $from, + string $to, + string $query, + string $aggregation = 'avg', + string $interval = '1m', + ): array|string { + try { + $params = [ + 'from' => $from, + 'to' => $to, + 'query' => $query, + ]; + + $response = $this->httpClient->request('GET', "https://api.{$this->site}/api/v1/query", [ + 'headers' => [ + 'DD-API-KEY' => $this->apiKey, + 'DD-APPLICATION-KEY' => $this->applicationKey, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting metrics: '.($data['error'] ?? 'Unknown error'); + } + + return [ + 'series' => array_map(fn ($series) => [ + 'metric' => $series['metric'], + 'display_name' => $series['display_name'], + 'unit' => $series['unit'] ?? '', + 'pointlist' => $series['pointlist'] ?? [], + 'scope' => $series['scope'] ?? '', + 'tag_set' => $series['tag_set'] ?? [], + ], $data['series'] ?? []), + 'from_date' => $data['from_date'], + 'to_date' => $data['to_date'], + 'query' => $data['query'], + 'message' => $data['message'] ?? '', + 'res_type' => $data['res_type'], + 'resp_version' => $data['resp_version'], + 'status' => $data['status'], + ]; + } catch (\Exception $e) { + return 'Error getting metrics: '.$e->getMessage(); + } + } + + /** + * Get Datadog logs. + * + * @param string $query Log search query + * @param string $from Start time (ISO 8601 or Unix timestamp) + * @param string $to End time (ISO 8601 or Unix timestamp) + * @param int $limit Number of logs to retrieve + * @param string $sort Sort order (timestamp, -timestamp) + * @param string $index Log index name + * + * @return array{ + * logs: array, + * attributes: array, + * }, + * date: string, + * host: string, + * source: string, + * service: string, + * status: string, + * tags: array, + * message: string, + * attributes: array, + * }>, + * nextLogId: string|null, + * status: string, + * }|string + */ + public function getLogs( + string $query, + string $from, + string $to, + int $limit = 1000, + string $sort = '-timestamp', + string $index = 'main', + ): array|string { + try { + $payload = [ + 'query' => $query, + 'from' => $from, + 'to' => $to, + 'limit' => min(max($limit, 1), 1000), + 'sort' => $sort, + 'index' => $index, + ]; + + $response = $this->httpClient->request('POST', "https://api.{$this->site}/api/v1/logs-queries/list", [ + 'headers' => [ + 'DD-API-KEY' => $this->apiKey, + 'DD-APPLICATION-KEY' => $this->applicationKey, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting logs: '.($data['error'] ?? 'Unknown error'); + } + + return [ + 'logs' => array_map(fn ($log) => [ + 'id' => $log['id'], + 'content' => [ + 'message' => $log['content']['message'] ?? '', + 'timestamp' => $log['content']['timestamp'] ?? '', + 'host' => $log['content']['host'] ?? '', + 'service' => $log['content']['service'] ?? '', + 'source' => $log['content']['source'] ?? '', + 'status' => $log['content']['status'] ?? '', + 'tags' => $log['content']['tags'] ?? [], + 'attributes' => $log['content']['attributes'] ?? [], + ], + 'date' => $log['date'], + 'host' => $log['host'], + 'source' => $log['source'], + 'service' => $log['service'], + 'status' => $log['status'], + 'tags' => $log['tags'] ?? [], + 'message' => $log['message'], + 'attributes' => $log['attributes'] ?? [], + ], $data['logs'] ?? []), + 'nextLogId' => $data['nextLogId'] ?? null, + 'status' => $data['status'], + ]; + } catch (\Exception $e) { + return 'Error getting logs: '.$e->getMessage(); + } + } + + /** + * Get Datadog alerts. + * + * @param int $perPage Number of alerts per page + * @param int $page Page number + * @param string $query Search query + * @param string $order Order by field (created, modified, name) + * @param string $direction Order direction (asc, desc) + * + * @return array, + * options: array, + * state: array{ + * groups: array, + * template_variables: array, + * name: string, + * message: string, + * query: string, + * type: string, + * priority: string, + * tags: array, + * options: array, + * restricted_roles: array, + * notify_audit: bool, + * no_data_timeframe: int|null, + * new_host_delay: int, + * new_group_delay: int, + * require_full_window: bool, + * notify_no_data: bool, + * renotify_interval: int|null, + * escalation_message: string|null, + * evaluation_delay: int, + * locked: bool, + * include_tags: bool, + * threshold_windows: array, + * thresholds: array, + * created_at: int, + * created_by: array{id: int, handle: string, email: string}, + * modified_at: int, + * modified_by: array{id: int, handle: string, email: string}, + * overall_state_modified: string, + * overall_state: string, + * org_id: int, + * restricted_roles: array, + * }, + * created: string, + * modified: string, + * restricted_roles: array, + * deleted: string|null, + * creator: array{id: int, handle: string, email: string, name: string}, + * multi: bool, + * }> + */ + public function getAlerts( + int $perPage = 50, + int $page = 0, + string $query = '', + string $order = 'created', + string $direction = 'desc', + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 0), + 'order' => $order, + 'direction' => $direction, + ]; + + if ($query) { + $params['query'] = $query; + } + + $response = $this->httpClient->request('GET', "https://api.{$this->site}/api/v1/monitor", [ + 'headers' => [ + 'DD-API-KEY' => $this->apiKey, + 'DD-APPLICATION-KEY' => $this->applicationKey, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return []; + } + + return array_map(fn ($alert) => [ + 'id' => $alert['id'], + 'name' => $alert['name'], + 'message' => $alert['message'], + 'query' => $alert['query'], + 'type' => $alert['type'], + 'priority' => $alert['priority'] ?? 'normal', + 'tags' => $alert['tags'] ?? [], + 'options' => $alert['options'] ?? [], + 'state' => [ + 'groups' => $alert['state']['groups'] ?? [], + 'template_variables' => $alert['state']['template_variables'] ?? [], + 'name' => $alert['state']['name'], + 'message' => $alert['state']['message'], + 'query' => $alert['state']['query'], + 'type' => $alert['state']['type'], + 'priority' => $alert['state']['priority'] ?? 'normal', + 'tags' => $alert['state']['tags'] ?? [], + 'options' => $alert['state']['options'] ?? [], + 'restricted_roles' => $alert['state']['restricted_roles'] ?? [], + 'notify_audit' => $alert['state']['notify_audit'] ?? false, + 'no_data_timeframe' => $alert['state']['no_data_timeframe'], + 'new_host_delay' => $alert['state']['new_host_delay'] ?? 300, + 'new_group_delay' => $alert['state']['new_group_delay'] ?? 300, + 'require_full_window' => $alert['state']['require_full_window'] ?? false, + 'notify_no_data' => $alert['state']['notify_no_data'] ?? false, + 'renotify_interval' => $alert['state']['renotify_interval'], + 'escalation_message' => $alert['state']['escalation_message'], + 'evaluation_delay' => $alert['state']['evaluation_delay'] ?? 0, + 'locked' => $alert['state']['locked'] ?? false, + 'include_tags' => $alert['state']['include_tags'] ?? true, + 'threshold_windows' => $alert['state']['threshold_windows'] ?? [], + 'thresholds' => $alert['state']['thresholds'] ?? [], + 'created_at' => $alert['state']['created_at'], + 'created_by' => [ + 'id' => $alert['state']['created_by']['id'], + 'handle' => $alert['state']['created_by']['handle'], + 'email' => $alert['state']['created_by']['email'], + ], + 'modified_at' => $alert['state']['modified_at'], + 'modified_by' => [ + 'id' => $alert['state']['modified_by']['id'], + 'handle' => $alert['state']['modified_by']['handle'], + 'email' => $alert['state']['modified_by']['email'], + ], + 'overall_state_modified' => $alert['state']['overall_state_modified'], + 'overall_state' => $alert['state']['overall_state'], + 'org_id' => $alert['state']['org_id'], + ], + 'created' => $alert['created'], + 'modified' => $alert['modified'], + 'restricted_roles' => $alert['restricted_roles'] ?? [], + 'deleted' => $alert['deleted'], + 'creator' => [ + 'id' => $alert['creator']['id'], + 'handle' => $alert['creator']['handle'], + 'email' => $alert['creator']['email'], + 'name' => $alert['creator']['name'], + ], + 'multi' => $alert['multi'] ?? false, + ], $data['monitors'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Datadog monitors. + * + * @param int $perPage Number of monitors per page + * @param int $page Page number + * @param string $query Search query + * @param string $order Order by field (created, modified, name) + * @param string $direction Order direction (asc, desc) + * + * @return array, + * options: array, + * state: array{ + * groups: array, + * template_variables: array, + * name: string, + * message: string, + * query: string, + * type: string, + * priority: string, + * tags: array, + * options: array, + * restricted_roles: array, + * notify_audit: bool, + * no_data_timeframe: int|null, + * new_host_delay: int, + * new_group_delay: int, + * require_full_window: bool, + * notify_no_data: bool, + * renotify_interval: int|null, + * escalation_message: string|null, + * evaluation_delay: int, + * locked: bool, + * include_tags: bool, + * threshold_windows: array, + * thresholds: array, + * created_at: int, + * created_by: array{id: int, handle: string, email: string}, + * modified_at: int, + * modified_by: array{id: int, handle: string, email: string}, + * overall_state_modified: string, + * overall_state: string, + * org_id: int, + * }, + * created: string, + * modified: string, + * restricted_roles: array, + * deleted: string|null, + * creator: array{id: int, handle: string, email: string, name: string}, + * multi: bool, + * }> + */ + public function getMonitors( + int $perPage = 50, + int $page = 0, + string $query = '', + string $order = 'created', + string $direction = 'desc', + ): array { + // This method is essentially the same as getAlerts since Datadog uses monitors for alerts + return $this->getAlerts($perPage, $page, $query, $order, $direction); + } + + /** + * Get Datadog events. + * + * @param string $from Start time (ISO 8601 or Unix timestamp) + * @param string $to End time (ISO 8601 or Unix timestamp) + * @param string $query Event search query + * @param int $limit Number of events to retrieve + * @param string $sort Sort order (timestamp, -timestamp, priority, -priority) + * @param string $aggregation Aggregation method (count, cardinality, pc75, pc90, pc95, pc98, pc99, sum, min, max, avg) + * + * @return array{ + * events: array, + * alert_type: string, + * aggregation_key: string, + * handle: string, + * url: string, + * is_aggregate: bool, + * can_delete: bool, + * can_edit: bool, + * device_name: string, + * related_event_id: int|null, + * host: string, + * resource: string, + * event_type: string, + * children: array, + * comments: array, + * }>, + * status: string, + * }|string + */ + public function getEvents( + string $from, + string $to, + string $query = '', + int $limit = 100, + string $sort = '-timestamp', + string $aggregation = 'count', + ): array|string { + try { + $params = [ + 'from' => $from, + 'to' => $to, + 'limit' => min(max($limit, 1), 1000), + 'sort' => $sort, + 'aggregation' => $aggregation, + ]; + + if ($query) { + $params['query'] = $query; + } + + $response = $this->httpClient->request('GET', "https://api.{$this->site}/api/v1/events", [ + 'headers' => [ + 'DD-API-KEY' => $this->apiKey, + 'DD-APPLICATION-KEY' => $this->applicationKey, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting events: '.($data['error'] ?? 'Unknown error'); + } + + return [ + 'events' => array_map(fn ($event) => [ + 'id' => $event['id'], + 'title' => $event['title'], + 'text' => $event['text'], + 'date_happened' => $event['date_happened'], + 'priority' => $event['priority'] ?? 'normal', + 'source' => $event['source'], + 'tags' => $event['tags'] ?? [], + 'alert_type' => $event['alert_type'] ?? 'info', + 'aggregation_key' => $event['aggregation_key'] ?? '', + 'handle' => $event['handle'] ?? '', + 'url' => $event['url'] ?? '', + 'is_aggregate' => $event['is_aggregate'] ?? false, + 'can_delete' => $event['can_delete'] ?? false, + 'can_edit' => $event['can_edit'] ?? false, + 'device_name' => $event['device_name'] ?? '', + 'related_event_id' => $event['related_event_id'], + 'host' => $event['host'] ?? '', + 'resource' => $event['resource'] ?? '', + 'event_type' => $event['event_type'] ?? '', + 'children' => $event['children'] ?? [], + 'comments' => array_map(fn ($comment) => [ + 'id' => $comment['id'], + 'message' => $comment['message'], + 'handle' => $comment['handle'], + 'created' => $comment['created'], + ], $event['comments'] ?? []), + ], $data['events'] ?? []), + 'status' => $data['status'] ?? 'ok', + ]; + } catch (\Exception $e) { + return 'Error getting events: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/DiscordBot.php b/src/agent/src/Toolbox/Tool/DiscordBot.php new file mode 100644 index 000000000..94aa8bef6 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/DiscordBot.php @@ -0,0 +1,393 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('discord_send_message', 'Tool that sends messages to Discord channels')] +#[AsTool('discord_get_messages', 'Tool that retrieves messages from Discord channels', method: 'getMessages')] +#[AsTool('discord_get_channels', 'Tool that lists Discord channels', method: 'getChannels')] +#[AsTool('discord_get_guild_info', 'Tool that gets Discord server information', method: 'getGuildInfo')] +#[AsTool('discord_create_embed', 'Tool that creates rich embeds for Discord messages', method: 'createEmbed')] +final readonly class DiscordBot +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $botToken, + private array $options = [], + ) { + } + + /** + * Send a message to a Discord channel. + * + * @param string $channelId Discord channel ID + * @param string $content Message content + * @param array $embed Optional rich embed + * @param array $components Optional message components (buttons, select menus) + * + * @return array{ + * id: string, + * channel_id: string, + * content: string, + * timestamp: string, + * author: array{ + * id: string, + * username: string, + * discriminator: string, + * avatar: string, + * bot: bool, + * }, + * }|string + */ + public function __invoke( + string $channelId, + #[With(maximum: 2000)] + string $content, + array $embed = [], + array $components = [], + ): array|string { + try { + $payload = [ + 'content' => $content, + ]; + + if (!empty($embed)) { + $payload['embeds'] = [$embed]; + } + + if (!empty($components)) { + $payload['components'] = $components; + } + + $response = $this->httpClient->request('POST', "https://discord.com/api/v10/channels/{$channelId}/messages", [ + 'headers' => [ + 'Authorization' => 'Bot '.$this->botToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + if (200 !== $response->getStatusCode()) { + $errorData = $response->toArray(false); + + return 'Error sending message: '.($errorData['message'] ?? 'Unknown error'); + } + + $data = $response->toArray(); + + return [ + 'id' => $data['id'], + 'channel_id' => $data['channel_id'], + 'content' => $data['content'], + 'timestamp' => $data['timestamp'], + 'author' => [ + 'id' => $data['author']['id'], + 'username' => $data['author']['username'], + 'discriminator' => $data['author']['discriminator'], + 'avatar' => $data['author']['avatar'], + 'bot' => $data['author']['bot'], + ], + ]; + } catch (\Exception $e) { + return 'Error sending message: '.$e->getMessage(); + } + } + + /** + * Get messages from a Discord channel. + * + * @param string $channelId Discord channel ID + * @param int $limit Maximum number of messages to retrieve (1-100) + * @param string $before Get messages before this message ID + * @param string $after Get messages after this message ID + * @param string $around Get messages around this message ID + * + * @return array>, + * attachments: array>, + * mentions: array>, + * }>|string + */ + public function getMessages( + string $channelId, + int $limit = 50, + string $before = '', + string $after = '', + string $around = '', + ): array|string { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), // Clamp between 1 and 100 + ]; + + if ($before) { + $params['before'] = $before; + } + if ($after) { + $params['after'] = $after; + } + if ($around) { + $params['around'] = $around; + } + + $response = $this->httpClient->request('GET', "https://discord.com/api/v10/channels/{$channelId}/messages", [ + 'headers' => [ + 'Authorization' => 'Bot '.$this->botToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + if (200 !== $response->getStatusCode()) { + $errorData = $response->toArray(false); + + return 'Error getting messages: '.($errorData['message'] ?? 'Unknown error'); + } + + $data = $response->toArray(); + $messages = []; + + foreach ($data as $message) { + $messages[] = [ + 'id' => $message['id'], + 'channel_id' => $message['channel_id'], + 'content' => $message['content'], + 'timestamp' => $message['timestamp'], + 'edited_timestamp' => $message['edited_timestamp'], + 'author' => [ + 'id' => $message['author']['id'], + 'username' => $message['author']['username'], + 'discriminator' => $message['author']['discriminator'], + 'avatar' => $message['author']['avatar'], + 'bot' => $message['author']['bot'], + ], + 'embeds' => $message['embeds'] ?? [], + 'attachments' => $message['attachments'] ?? [], + 'mentions' => $message['mentions'] ?? [], + ]; + } + + return $messages; + } catch (\Exception $e) { + return 'Error getting messages: '.$e->getMessage(); + } + } + + /** + * Get channels from a Discord guild (server). + * + * @param string $guildId Discord guild ID + * + * @return array>, + * }>|string + */ + public function getChannels(string $guildId): array|string + { + try { + $response = $this->httpClient->request('GET', "https://discord.com/api/v10/guilds/{$guildId}/channels", [ + 'headers' => [ + 'Authorization' => 'Bot '.$this->botToken, + ], + ]); + + if (200 !== $response->getStatusCode()) { + $errorData = $response->toArray(false); + + return 'Error getting channels: '.($errorData['message'] ?? 'Unknown error'); + } + + $data = $response->toArray(); + $channels = []; + + foreach ($data as $channel) { + $channels[] = [ + 'id' => $channel['id'], + 'name' => $channel['name'], + 'type' => $channel['type'], + 'position' => $channel['position'], + 'topic' => $channel['topic'] ?? null, + 'nsfw' => $channel['nsfw'] ?? false, + 'parent_id' => $channel['parent_id'] ?? null, + 'permission_overwrites' => $channel['permission_overwrites'] ?? [], + ]; + } + + return $channels; + } catch (\Exception $e) { + return 'Error getting channels: '.$e->getMessage(); + } + } + + /** + * Get Discord guild (server) information. + * + * @param string $guildId Discord guild ID + * + * @return array{ + * id: string, + * name: string, + * description: string|null, + * icon: string|null, + * splash: string|null, + * banner: string|null, + * owner_id: string, + * member_count: int, + * features: array, + * verification_level: int, + * explicit_content_filter: int, + * default_message_notifications: int, + * mfa_level: int, + * premium_tier: int, + * premium_subscription_count: int, + * }|string + */ + public function getGuildInfo(string $guildId): array|string + { + try { + $response = $this->httpClient->request('GET', "https://discord.com/api/v10/guilds/{$guildId}", [ + 'headers' => [ + 'Authorization' => 'Bot '.$this->botToken, + ], + 'query' => [ + 'with_counts' => 'true', + ], + ]); + + if (200 !== $response->getStatusCode()) { + $errorData = $response->toArray(false); + + return 'Error getting guild info: '.($errorData['message'] ?? 'Unknown error'); + } + + $data = $response->toArray(); + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'icon' => $data['icon'] ?? null, + 'splash' => $data['splash'] ?? null, + 'banner' => $data['banner'] ?? null, + 'owner_id' => $data['owner_id'], + 'member_count' => $data['member_count'] ?? 0, + 'features' => $data['features'] ?? [], + 'verification_level' => $data['verification_level'], + 'explicit_content_filter' => $data['explicit_content_filter'], + 'default_message_notifications' => $data['default_message_notifications'], + 'mfa_level' => $data['mfa_level'], + 'premium_tier' => $data['premium_tier'], + 'premium_subscription_count' => $data['premium_subscription_count'] ?? 0, + ]; + } catch (\Exception $e) { + return 'Error getting guild info: '.$e->getMessage(); + } + } + + /** + * Create a rich embed for Discord messages. + * + * @param string $title Embed title + * @param string $description Embed description + * @param string $color Embed color (hex code without #) + * @param array $fields Embed fields + * @param string $footer Embed footer text + * @param string $thumbnail Thumbnail URL + * @param string $image Image URL + * + * @return array + */ + public function createEmbed( + string $title = '', + string $description = '', + string $color = '', + array $fields = [], + string $footer = '', + string $thumbnail = '', + string $image = '', + ): array { + $embed = []; + + if ($title) { + $embed['title'] = $title; + } + + if ($description) { + $embed['description'] = $description; + } + + if ($color) { + $embed['color'] = hexdec($color); + } + + if (!empty($fields)) { + $embed['fields'] = $fields; + } + + if ($footer) { + $embed['footer'] = ['text' => $footer]; + } + + if ($thumbnail) { + $embed['thumbnail'] = ['url' => $thumbnail]; + } + + if ($image) { + $embed['image'] = ['url' => $image]; + } + + $embed['timestamp'] = date('c'); // ISO 8601 timestamp + + return $embed; + } + + /** + * Send a message with a rich embed. + * + * @param string $channelId Discord channel ID + * @param string $content Message content + * @param array $embedData Embed data + * + * @return array|string + */ + public function sendEmbedMessage(string $channelId, string $content, array $embedData): array|string + { + return $this->__invoke($channelId, $content, $embedData); + } +} diff --git a/src/agent/src/Toolbox/Tool/Docker.php b/src/agent/src/Toolbox/Tool/Docker.php new file mode 100644 index 000000000..3a1fe9549 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Docker.php @@ -0,0 +1,466 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('docker_list_images', 'Tool that lists Docker images')] +#[AsTool('docker_list_containers', 'Tool that lists Docker containers', method: 'listContainers')] +#[AsTool('docker_build_image', 'Tool that builds Docker images', method: 'buildImage')] +#[AsTool('docker_run_container', 'Tool that runs Docker containers', method: 'runContainer')] +#[AsTool('docker_stop_container', 'Tool that stops Docker containers', method: 'stopContainer')] +#[AsTool('docker_remove_container', 'Tool that removes Docker containers', method: 'removeContainer')] +final readonly class Docker +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $dockerHost = 'unix:///var/run/docker.sock', + private string $apiVersion = 'v1.43', + private array $options = [], + ) { + } + + /** + * List Docker images. + * + * @param bool $all Show all images (including intermediate) + * @param string $filters JSON string of filters + * @param bool $digests Show digests + * + * @return array|null, + * RepoDigests: array|null, + * Created: int, + * Size: int, + * SharedSize: int, + * VirtualSize: int, + * Labels: array|null, + * Containers: int, + * }> + */ + public function __invoke( + bool $all = false, + string $filters = '', + bool $digests = false, + ): array { + try { + $params = [ + 'all' => $all ? 'true' : 'false', + 'digests' => $digests ? 'true' : 'false', + ]; + + if ($filters) { + $params['filters'] = $filters; + } + + $response = $this->httpClient->request('GET', $this->buildUrl('/images/json'), [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['message'])) { + return []; + } + + return array_map(fn ($image) => [ + 'Id' => $image['Id'], + 'ParentId' => $image['ParentId'], + 'RepoTags' => $image['RepoTags'], + 'RepoDigests' => $image['RepoDigests'], + 'Created' => $image['Created'], + 'Size' => $image['Size'], + 'SharedSize' => $image['SharedSize'], + 'VirtualSize' => $image['VirtualSize'], + 'Labels' => $image['Labels'], + 'Containers' => $image['Containers'], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * List Docker containers. + * + * @param bool $all Show all containers (including stopped) + * @param int $limit Limit number of containers + * @param bool $size Show container sizes + * @param string $filters JSON string of filters + * + * @return array, + * Image: string, + * ImageID: string, + * Command: string, + * Created: int, + * Ports: array, + * SizeRw: int|null, + * SizeRootFs: int|null, + * Labels: array, + * State: string, + * Status: string, + * HostConfig: array{ + * NetworkMode: string, + * }, + * NetworkSettings: array{ + * Networks: array, + * }, + * Mounts: array, + * }> + */ + public function listContainers( + bool $all = false, + int $limit = 0, + bool $size = false, + string $filters = '', + ): array { + try { + $params = [ + 'all' => $all ? 'true' : 'false', + 'size' => $size ? 'true' : 'false', + ]; + + if ($limit > 0) { + $params['limit'] = $limit; + } + if ($filters) { + $params['filters'] = $filters; + } + + $response = $this->httpClient->request('GET', $this->buildUrl('/containers/json'), [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['message'])) { + return []; + } + + return array_map(fn ($container) => [ + 'Id' => $container['Id'], + 'Names' => $container['Names'], + 'Image' => $container['Image'], + 'ImageID' => $container['ImageID'], + 'Command' => $container['Command'], + 'Created' => $container['Created'], + 'Ports' => array_map(fn ($port) => [ + 'IP' => $port['IP'], + 'PrivatePort' => $port['PrivatePort'], + 'PublicPort' => $port['PublicPort'], + 'Type' => $port['Type'], + ], $container['Ports'] ?? []), + 'SizeRw' => $container['SizeRw'], + 'SizeRootFs' => $container['SizeRootFs'], + 'Labels' => $container['Labels'] ?? [], + 'State' => $container['State'], + 'Status' => $container['Status'], + 'HostConfig' => [ + 'NetworkMode' => $container['HostConfig']['NetworkMode'], + ], + 'NetworkSettings' => [ + 'Networks' => $container['NetworkSettings']['Networks'] ?? [], + ], + 'Mounts' => array_map(fn ($mount) => [ + 'Type' => $mount['Type'], + 'Name' => $mount['Name'], + 'Source' => $mount['Source'], + 'Destination' => $mount['Destination'], + 'Driver' => $mount['Driver'], + 'Mode' => $mount['Mode'], + 'RW' => $mount['RW'], + 'Propagation' => $mount['Propagation'], + ], $container['Mounts'] ?? []), + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Build Docker image. + * + * @param string $dockerfile Dockerfile content + * @param string $context Build context (directory path or tar stream) + * @param string $tag Image tag + * @param bool $noCache Disable build cache + * @param bool $remove Remove intermediate containers + * @param bool $forceRemove Force removal of intermediate containers + * @param bool $pull Always pull base images + * + * @return array{ + * stream: string, + * aux: array{ID: string}|null, + * error: string|null, + * errorDetail: array{message: string}|null, + * }|string + */ + public function buildImage( + string $dockerfile, + string $context, + string $tag, + bool $noCache = false, + bool $remove = true, + bool $forceRemove = false, + bool $pull = false, + ): array|string { + try { + $params = [ + 'dockerfile' => $dockerfile, + 't' => $tag, + 'nocache' => $noCache ? 'true' : 'false', + 'rm' => $remove ? 'true' : 'false', + 'forcerm' => $forceRemove ? 'true' : 'false', + 'pull' => $pull ? 'true' : 'false', + ]; + + $response = $this->httpClient->request('POST', $this->buildUrl('/build'), [ + 'headers' => [ + 'Content-Type' => 'application/tar', + ], + 'query' => array_merge($this->options, $params), + 'body' => $context, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error building image: '.($data['error'] ?? 'Unknown error'); + } + + return [ + 'stream' => $data['stream'] ?? '', + 'aux' => $data['aux'] ?? null, + 'error' => $data['error'] ?? null, + 'errorDetail' => $data['errorDetail'] ?? null, + ]; + } catch (\Exception $e) { + return 'Error building image: '.$e->getMessage(); + } + } + + /** + * Run Docker container. + * + * @param string $image Image name + * @param string $name Container name + * @param array $cmd Command to run + * @param array $env Environment variables + * @param array $ports Port mappings (host_port:container_port) + * @param array $volumes Volume mappings (host_path:container_path) + * @param bool $detach Run in detached mode + * @param bool $interactive Keep STDIN open + * @param bool $tty Allocate a pseudo-TTY + * @param string $workingDir Working directory + * @param string $user Username or UID + * @param string $network Network name + * + * @return array{ + * Id: string, + * Warnings: array|null, + * }|string + */ + public function runContainer( + string $image, + string $name = '', + array $cmd = [], + array $env = [], + array $ports = [], + array $volumes = [], + bool $detach = true, + bool $interactive = false, + bool $tty = false, + string $workingDir = '', + string $user = '', + string $network = '', + ): array|string { + try { + $payload = [ + 'Image' => $image, + 'Cmd' => $cmd, + 'Env' => $env, + 'AttachStdin' => $interactive, + 'AttachStdout' => true, + 'AttachStderr' => true, + 'Tty' => $tty, + 'OpenStdin' => $interactive, + 'StdinOnce' => false, + 'HostConfig' => [], + ]; + + if ($name) { + $payload['name'] = $name; + } + if ($workingDir) { + $payload['WorkingDir'] = $workingDir; + } + if ($user) { + $payload['User'] = $user; + } + + // Port bindings + if (!empty($ports)) { + $payload['HostConfig']['PortBindings'] = []; + $payload['ExposedPorts'] = []; + foreach ($ports as $hostPort => $containerPort) { + $payload['HostConfig']['PortBindings']["{$containerPort}/tcp"] = [['HostPort' => (string) $hostPort]]; + $payload['ExposedPorts']["{$containerPort}/tcp"] = new \stdClass(); + } + } + + // Volume bindings + if (!empty($volumes)) { + $payload['HostConfig']['Binds'] = []; + foreach ($volumes as $hostPath => $containerPath) { + $payload['HostConfig']['Binds'][] = "{$hostPath}:{$containerPath}"; + } + } + + // Network + if ($network) { + $payload['HostConfig']['NetworkMode'] = $network; + } + + $response = $this->httpClient->request('POST', $this->buildUrl('/containers/create'), [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['message'])) { + return 'Error creating container: '.$data['message']; + } + + $containerId = $data['Id']; + + // Start the container + if ($detach) { + $startResponse = $this->httpClient->request('POST', $this->buildUrl("/containers/{$containerId}/start")); + if (204 !== $startResponse->getStatusCode()) { + return 'Error starting container: '.$startResponse->getContent(); + } + } + + return [ + 'Id' => $containerId, + 'Warnings' => $data['Warnings'] ?? null, + ]; + } catch (\Exception $e) { + return 'Error running container: '.$e->getMessage(); + } + } + + /** + * Stop Docker container. + * + * @param string $containerId Container ID or name + * @param int $timeout Timeout in seconds + */ + public function stopContainer( + string $containerId, + int $timeout = 10, + ): string { + try { + $params = [ + 't' => $timeout, + ]; + + $response = $this->httpClient->request('POST', $this->buildUrl("/containers/{$containerId}/stop"), [ + 'query' => array_merge($this->options, $params), + ]); + + if (204 === $response->getStatusCode()) { + return 'Container stopped successfully'; + } + + $data = $response->toArray(); + + return 'Error stopping container: '.($data['message'] ?? 'Unknown error'); + } catch (\Exception $e) { + return 'Error stopping container: '.$e->getMessage(); + } + } + + /** + * Remove Docker container. + * + * @param string $containerId Container ID or name + * @param bool $force Force removal + * @param bool $removeVolumes Remove associated volumes + */ + public function removeContainer( + string $containerId, + bool $force = false, + bool $removeVolumes = false, + ): string { + try { + $params = [ + 'force' => $force ? 'true' : 'false', + 'v' => $removeVolumes ? 'true' : 'false', + ]; + + $response = $this->httpClient->request('DELETE', $this->buildUrl("/containers/{$containerId}"), [ + 'query' => array_merge($this->options, $params), + ]); + + if (204 === $response->getStatusCode()) { + return 'Container removed successfully'; + } + + $data = $response->toArray(); + + return 'Error removing container: '.($data['message'] ?? 'Unknown error'); + } catch (\Exception $e) { + return 'Error removing container: '.$e->getMessage(); + } + } + + /** + * Build Docker API URL. + */ + private function buildUrl(string $endpoint): string + { + $baseUrl = str_starts_with($this->dockerHost, 'unix://') + ? 'http://localhost' + : $this->dockerHost; + + return "{$baseUrl}/{$this->apiVersion}{$endpoint}"; + } +} diff --git a/src/agent/src/Toolbox/Tool/Dropbox.php b/src/agent/src/Toolbox/Tool/Dropbox.php new file mode 100644 index 000000000..7b5515103 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Dropbox.php @@ -0,0 +1,519 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('dropbox_list_files', 'Tool that lists files and folders in Dropbox')] +#[AsTool('dropbox_upload_file', 'Tool that uploads files to Dropbox', method: 'uploadFile')] +#[AsTool('dropbox_download_file', 'Tool that downloads files from Dropbox', method: 'downloadFile')] +#[AsTool('dropbox_create_folder', 'Tool that creates folders in Dropbox', method: 'createFolder')] +#[AsTool('dropbox_share_file', 'Tool that shares files on Dropbox', method: 'shareFile')] +#[AsTool('dropbox_search_files', 'Tool that searches for files in Dropbox', method: 'searchFiles')] +final readonly class Dropbox +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private array $options = [], + ) { + } + + /** + * List files and folders in Dropbox. + * + * @param string $path Path to list (use '' for root) + * @param bool $recursive Whether to list recursively + * @param int $limit Maximum number of items to return + * + * @return array|null, + * }> + */ + public function __invoke( + string $path = '', + bool $recursive = false, + int $limit = 100, + ): array { + try { + $payload = [ + 'path' => $path, + 'recursive' => $recursive, + 'limit' => min(max($limit, 1), 2000), + 'include_media_info' => true, + 'include_deleted' => false, + ]; + + $response = $this->httpClient->request('POST', 'https://api.dropboxapi.com/2/files/list_folder', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (!isset($data['entries'])) { + return []; + } + + $files = []; + foreach ($data['entries'] as $entry) { + $files[] = [ + 'id' => $entry['id'], + 'name' => $entry['name'], + 'path_display' => $entry['path_display'], + 'path_lower' => $entry['path_lower'], + 'size' => $entry['size'] ?? 0, + 'client_modified' => $entry['client_modified'] ?? '', + 'server_modified' => $entry['server_modified'] ?? '', + 'content_hash' => $entry['content_hash'] ?? '', + 'tag' => $entry['.tag'], + 'is_downloadable' => $entry['is_downloadable'] ?? false, + 'has_explicit_shared_members' => $entry['has_explicit_shared_members'] ?? false, + 'media_info' => $entry['media_info'] ?? null, + ]; + } + + return $files; + } catch (\Exception $e) { + return []; + } + } + + /** + * Upload a file to Dropbox. + * + * @param string $filePath Path to the file to upload + * @param string $dropboxPath Destination path in Dropbox + * @param string $mode Upload mode (add, overwrite, update) + * @param bool $autorename Whether to autorename if file exists + * + * @return array{ + * id: string, + * name: string, + * path_display: string, + * path_lower: string, + * size: int, + * client_modified: string, + * server_modified: string, + * content_hash: string, + * }|string + */ + public function uploadFile( + string $filePath, + string $dropboxPath, + string $mode = 'add', + bool $autorename = true, + ): array|string { + try { + if (!file_exists($filePath)) { + return 'Error: File does not exist'; + } + + $fileContent = file_get_contents($filePath); + $fileName = basename($filePath); + $fullPath = rtrim($dropboxPath, '/').'/'.$fileName; + + $response = $this->httpClient->request('POST', 'https://content.dropboxapi.com/2/files/upload', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/octet-stream', + 'Dropbox-API-Arg' => json_encode([ + 'path' => $fullPath, + 'mode' => $mode, + 'autorename' => $autorename, + 'mute' => false, + ]), + ], + 'body' => $fileContent, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error uploading file: '.$data['error']['error_summary']; + } + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'path_display' => $data['path_display'], + 'path_lower' => $data['path_lower'], + 'size' => $data['size'], + 'client_modified' => $data['client_modified'], + 'server_modified' => $data['server_modified'], + 'content_hash' => $data['content_hash'], + ]; + } catch (\Exception $e) { + return 'Error uploading file: '.$e->getMessage(); + } + } + + /** + * Download a file from Dropbox. + * + * @param string $dropboxPath Path to the file in Dropbox + * @param string $localPath Local path to save the file + * + * @return array{ + * file_path: string, + * file_size: int, + * saved_path: string, + * metadata: array{ + * id: string, + * name: string, + * size: int, + * client_modified: string, + * server_modified: string, + * }, + * }|string + */ + public function downloadFile(string $dropboxPath, string $localPath): array|string + { + try { + $response = $this->httpClient->request('POST', 'https://content.dropboxapi.com/2/files/download', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Dropbox-API-Arg' => json_encode(['path' => $dropboxPath]), + ], + ]); + + if (200 !== $response->getStatusCode()) { + return 'Error downloading file: File not found or access denied'; + } + + $fileContent = $response->getContent(); + $metadataHeader = $response->getHeaders()['dropbox-api-result'][0] ?? '{}'; + $metadata = json_decode($metadataHeader, true); + + // Save to local path + if (is_dir($localPath)) { + $localPath = rtrim($localPath, '/').'/'.$metadata['name']; + } + + file_put_contents($localPath, $fileContent); + + return [ + 'file_path' => $dropboxPath, + 'file_size' => \strlen($fileContent), + 'saved_path' => $localPath, + 'metadata' => [ + 'id' => $metadata['id'] ?? '', + 'name' => $metadata['name'] ?? '', + 'size' => $metadata['size'] ?? 0, + 'client_modified' => $metadata['client_modified'] ?? '', + 'server_modified' => $metadata['server_modified'] ?? '', + ], + ]; + } catch (\Exception $e) { + return 'Error downloading file: '.$e->getMessage(); + } + } + + /** + * Create a folder in Dropbox. + * + * @param string $path Path where to create the folder + * @param bool $autorename Whether to autorename if folder exists + * + * @return array{ + * id: string, + * name: string, + * path_display: string, + * path_lower: string, + * client_modified: string, + * server_modified: string, + * }|string + */ + public function createFolder(string $path, bool $autorename = true): array|string + { + try { + $response = $this->httpClient->request('POST', 'https://api.dropboxapi.com/2/files/create_folder_v2', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'path' => $path, + 'autorename' => $autorename, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating folder: '.$data['error']['error_summary']; + } + + $metadata = $data['metadata']; + + return [ + 'id' => $metadata['id'], + 'name' => $metadata['name'], + 'path_display' => $metadata['path_display'], + 'path_lower' => $metadata['path_lower'], + 'client_modified' => $metadata['client_modified'] ?? '', + 'server_modified' => $metadata['server_modified'] ?? '', + ]; + } catch (\Exception $e) { + return 'Error creating folder: '.$e->getMessage(); + } + } + + /** + * Share a file on Dropbox. + * + * @param string $path Path to the file to share + * @param string $accessLevel Access level (viewer, editor, owner) + * @param bool $allowDownload Whether to allow downloads + * @param string $password Optional password for the link + * @param string $expires Expiration date (YYYY-MM-DD) + * + * @return array{ + * url: string, + * name: string, + * link_permissions: array{ + * can_revoke: bool, + * can_remove_password: bool, + * can_update_password: bool, + * can_update_expiry: bool, + * can_update_audience: bool, + * can_set_password: bool, + * can_set_expiry: bool, + * can_set_audience: bool, + * }, + * expires: string, + * path_lower: string, + * }|string + */ + public function shareFile( + string $path, + string $accessLevel = 'viewer', + bool $allowDownload = true, + string $password = '', + string $expires = '', + ): array|string { + try { + $settings = [ + 'requested_visibility' => 'public', + 'access' => $accessLevel, + 'allow_download' => $allowDownload, + ]; + + if ($password) { + $settings['password'] = $password; + } + + if ($expires) { + $settings['expires'] = $expires.'T23:59:59Z'; + } + + $response = $this->httpClient->request('POST', 'https://api.dropboxapi.com/2/sharing/create_shared_link_with_settings', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'path' => $path, + 'settings' => $settings, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error sharing file: '.$data['error']['error_summary']; + } + + return [ + 'url' => $data['url'], + 'name' => $data['name'], + 'link_permissions' => [ + 'can_revoke' => $data['link_permissions']['can_revoke'] ?? false, + 'can_remove_password' => $data['link_permissions']['can_remove_password'] ?? false, + 'can_update_password' => $data['link_permissions']['can_update_password'] ?? false, + 'can_update_expiry' => $data['link_permissions']['can_update_expiry'] ?? false, + 'can_update_audience' => $data['link_permissions']['can_update_audience'] ?? false, + 'can_set_password' => $data['link_permissions']['can_set_password'] ?? false, + 'can_set_expiry' => $data['link_permissions']['can_set_expiry'] ?? false, + 'can_set_audience' => $data['link_permissions']['can_set_audience'] ?? false, + ], + 'expires' => $data['expires'] ?? '', + 'path_lower' => $data['path_lower'], + ]; + } catch (\Exception $e) { + return 'Error sharing file: '.$e->getMessage(); + } + } + + /** + * Search for files in Dropbox. + * + * @param string $query Search query + * @param string $path Path to search in (use '' for root) + * @param int $maxResults Maximum number of results + * @param string $fileCategory File category filter (image, document, video, audio, other) + * + * @return array + */ + public function searchFiles( + #[With(maximum: 500)] + string $query, + string $path = '', + int $maxResults = 20, + string $fileCategory = '', + ): array { + try { + $searchOptions = [ + 'path' => $path, + 'max_results' => min(max($maxResults, 1), 100), + 'file_status' => 'active', + ]; + + if ($fileCategory) { + $searchOptions['file_categories'] = [$fileCategory]; + } + + $response = $this->httpClient->request('POST', 'https://api.dropboxapi.com/2/files/search_v2', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'query' => $query, + 'options' => $searchOptions, + ], + ]); + + $data = $response->toArray(); + + if (!isset($data['matches'])) { + return []; + } + + $results = []; + foreach ($data['matches'] as $match) { + $results[] = [ + 'match_type' => [ + 'tag' => $match['match_type']['tag'] ?? 'filename', + ], + 'metadata' => [ + 'id' => $match['metadata']['id'] ?? '', + 'name' => $match['metadata']['name'] ?? '', + 'path_display' => $match['metadata']['path_display'] ?? '', + 'path_lower' => $match['metadata']['path_lower'] ?? '', + 'size' => $match['metadata']['size'] ?? 0, + 'client_modified' => $match['metadata']['client_modified'] ?? '', + 'server_modified' => $match['metadata']['server_modified'] ?? '', + 'content_hash' => $match['metadata']['content_hash'] ?? '', + 'tag' => $match['metadata']['.tag'] ?? 'file', + ], + ]; + } + + return $results; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get file metadata from Dropbox. + * + * @param string $path Path to the file + * + * @return array{ + * id: string, + * name: string, + * path_display: string, + * path_lower: string, + * size: int, + * client_modified: string, + * server_modified: string, + * content_hash: string, + * tag: string, + * is_downloadable: bool, + * has_explicit_shared_members: bool, + * }|string + */ + public function getFileMetadata(string $path): array|string + { + try { + $response = $this->httpClient->request('POST', 'https://api.dropboxapi.com/2/files/get_metadata', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'path' => $path, + 'include_media_info' => true, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting file metadata: '.$data['error']['error_summary']; + } + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'path_display' => $data['path_display'], + 'path_lower' => $data['path_lower'], + 'size' => $data['size'] ?? 0, + 'client_modified' => $data['client_modified'] ?? '', + 'server_modified' => $data['server_modified'] ?? '', + 'content_hash' => $data['content_hash'] ?? '', + 'tag' => $data['.tag'], + 'is_downloadable' => $data['is_downloadable'] ?? false, + 'has_explicit_shared_members' => $data['has_explicit_shared_members'] ?? false, + ]; + } catch (\Exception $e) { + return 'Error getting file metadata: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/DuckDuckGo.php b/src/agent/src/Toolbox/Tool/DuckDuckGo.php new file mode 100644 index 000000000..83f253f4b --- /dev/null +++ b/src/agent/src/Toolbox/Tool/DuckDuckGo.php @@ -0,0 +1,164 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('duckduckgo_search', 'Tool that searches the web using DuckDuckGo Search')] +#[AsTool('duckduckgo_results_json', 'Tool that searches DuckDuckGo and returns structured JSON results', method: 'searchJson')] +final readonly class DuckDuckGo +{ + /** + * @param array $options Additional search options + */ + public function __construct( + private HttpClientInterface $httpClient, + private array $options = [], + ) { + } + + /** + * @param string $query the search query term + * @param int $maxResults The maximum number of search results to return + * + * @return string Formatted search results + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + int $maxResults = 10, + ): string { + $results = $this->performSearch($query, $maxResults); + + if (empty($results)) { + return 'No results found for the given query.'; + } + + $formattedResults = []; + foreach ($results as $result) { + $formattedResults[] = \sprintf( + 'title: %s, snippet: %s, link: %s, date: %s, source: %s', + $result['title'] ?? 'N/A', + $result['snippet'] ?? 'N/A', + $result['link'] ?? 'N/A', + $result['date'] ?? 'N/A', + $result['source'] ?? 'N/A' + ); + } + + return '['.implode('], [', $formattedResults).']'; + } + + /** + * @param string $query the search query term + * @param int $maxResults The maximum number of search results to return + * + * @return array + */ + public function searchJson( + #[With(maximum: 500)] + string $query, + int $maxResults = 4, + ): array { + return $this->performSearch($query, $maxResults); + } + + /** + * @param string $query the search query term + * @param int $maxResults The maximum number of search results to return + * + * @return array + */ + private function performSearch(string $query, int $maxResults): array + { + try { + // DuckDuckGo Instant Answer API + $response = $this->httpClient->request('GET', 'https://api.duckduckgo.com/', [ + 'query' => array_merge($this->options, [ + 'q' => $query, + 'format' => 'json', + 'no_html' => '1', + 'skip_disambig' => '1', + ]), + ]); + + $data = $response->toArray(); + $results = []; + + // Add abstract if available + if (!empty($data['Abstract'])) { + $results[] = [ + 'title' => $data['Heading'] ?? $query, + 'snippet' => $data['Abstract'], + 'link' => $data['AbstractURL'] ?? '', + 'date' => '', + 'source' => $data['AbstractSource'] ?? 'DuckDuckGo', + ]; + } + + // Add related topics + if (!empty($data['RelatedTopics'])) { + foreach (\array_slice($data['RelatedTopics'], 0, $maxResults - \count($results)) as $topic) { + if (isset($topic['Text']) && isset($topic['FirstURL'])) { + $results[] = [ + 'title' => $topic['Text'], + 'snippet' => $topic['Text'], + 'link' => $topic['FirstURL'], + 'date' => '', + 'source' => 'DuckDuckGo Related', + ]; + } + } + } + + // If no results from Instant Answer API, try web search simulation + if (empty($results)) { + $results[] = [ + 'title' => "Search results for: {$query}", + 'snippet' => 'DuckDuckGo search results would be displayed here. Note: This is a simplified implementation.', + 'link' => 'https://duckduckgo.com/?q='.urlencode($query), + 'date' => date('Y-m-d'), + 'source' => 'DuckDuckGo Web Search', + ]; + } + + return \array_slice($results, 0, $maxResults); + } catch (\Exception $e) { + return [ + [ + 'title' => 'Search Error', + 'snippet' => 'Unable to perform search: '.$e->getMessage(), + 'link' => '', + 'date' => '', + 'source' => 'Error', + ], + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/E2bDataAnalysis.php b/src/agent/src/Toolbox/Tool/E2bDataAnalysis.php new file mode 100644 index 000000000..ff28b6fd3 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/E2bDataAnalysis.php @@ -0,0 +1,643 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('e2b_run_python', 'Tool that runs Python code in E2B sandbox')] +#[AsTool('e2b_run_notebook', 'Tool that runs Jupyter notebook in E2B', method: 'runNotebook')] +#[AsTool('e2b_upload_file', 'Tool that uploads files to E2B sandbox', method: 'uploadFile')] +#[AsTool('e2b_download_file', 'Tool that downloads files from E2B sandbox', method: 'downloadFile')] +#[AsTool('e2b_list_files', 'Tool that lists files in E2B sandbox', method: 'listFiles')] +#[AsTool('e2b_install_package', 'Tool that installs Python packages in E2B', method: 'installPackage')] +#[AsTool('e2b_run_sql', 'Tool that runs SQL queries in E2B', method: 'runSql')] +#[AsTool('e2b_create_sandbox', 'Tool that creates E2B sandbox', method: 'createSandbox')] +final readonly class E2bDataAnalysis +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.e2b.dev', + private array $options = [], + ) { + } + + /** + * Run Python code in E2B sandbox. + * + * @param string $code Python code to execute + * @param string $sandboxId E2B sandbox ID + * @param int $timeout Execution timeout in seconds + * @param array $environment Environment variables + * + * @return array{ + * success: bool, + * output: string, + * stderr: string, + * exitCode: int, + * executionTime: float, + * logs: array, + * error: string, + * } + */ + public function __invoke( + string $code, + string $sandboxId, + int $timeout = 30, + array $environment = [], + ): array { + try { + $requestData = [ + 'code' => $code, + 'timeout' => max(1, min($timeout, 300)), + 'environment' => $environment, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/sandboxes/{$sandboxId}/execute", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'output' => $data['output'] ?? '', + 'stderr' => $data['stderr'] ?? '', + 'exitCode' => $data['exit_code'] ?? 0, + 'executionTime' => $data['execution_time'] ?? 0.0, + 'logs' => array_map(fn ($log) => [ + 'timestamp' => $log['timestamp'] ?? '', + 'level' => $log['level'] ?? 'info', + 'message' => $log['message'] ?? '', + ], $data['logs'] ?? []), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'stderr' => $e->getMessage(), + 'exitCode' => 1, + 'executionTime' => 0.0, + 'logs' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Run Jupyter notebook in E2B. + * + * @param string $notebookPath Path to notebook file + * @param string $sandboxId E2B sandbox ID + * @param bool $executeAll Execute all cells + * @param array $cellIndices Specific cell indices to execute + * + * @return array{ + * success: bool, + * results: array, + * totalExecutionTime: float, + * error: string, + * } + */ + public function runNotebook( + string $notebookPath, + string $sandboxId, + bool $executeAll = true, + array $cellIndices = [], + ): array { + try { + $requestData = [ + 'notebook_path' => $notebookPath, + 'execute_all' => $executeAll, + 'cell_indices' => $cellIndices, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/sandboxes/{$sandboxId}/notebook/execute", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'results' => array_map(fn ($result) => [ + 'cellIndex' => $result['cell_index'] ?? 0, + 'output' => $result['output'] ?? '', + 'stderr' => $result['stderr'] ?? '', + 'exitCode' => $result['exit_code'] ?? 0, + 'executionTime' => $result['execution_time'] ?? 0.0, + ], $data['results'] ?? []), + 'totalExecutionTime' => $data['total_execution_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'results' => [], + 'totalExecutionTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Upload file to E2B sandbox. + * + * @param string $filePath Local file path + * @param string $sandboxId E2B sandbox ID + * @param string $destination Destination path in sandbox + * + * @return array{ + * success: bool, + * filePath: string, + * destination: string, + * fileSize: int, + * message: string, + * error: string, + * } + */ + public function uploadFile( + string $filePath, + string $sandboxId, + string $destination = '', + ): array { + try { + if (!file_exists($filePath)) { + throw new \InvalidArgumentException("File not found: {$filePath}."); + } + + $fileContent = file_get_contents($filePath); + $fileName = basename($filePath); + $destination = $destination ?: $fileName; + + $requestData = [ + 'file_content' => base64_encode($fileContent), + 'destination' => $destination, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/sandboxes/{$sandboxId}/files", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'filePath' => $filePath, + 'destination' => $destination, + 'fileSize' => \strlen($fileContent), + 'message' => 'File uploaded successfully', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'filePath' => $filePath, + 'destination' => $destination, + 'fileSize' => 0, + 'message' => 'Failed to upload file', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Download file from E2B sandbox. + * + * @param string $sandboxPath Path to file in sandbox + * @param string $sandboxId E2B sandbox ID + * @param string $localPath Local path to save file + * + * @return array{ + * success: bool, + * sandboxPath: string, + * localPath: string, + * fileSize: int, + * message: string, + * error: string, + * } + */ + public function downloadFile( + string $sandboxPath, + string $sandboxId, + string $localPath = '', + ): array { + try { + $response = $this->httpClient->request('GET', "{$this->baseUrl}/sandboxes/{$sandboxId}/files/{$sandboxPath}", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + ], + ] + $this->options); + + $fileContent = $response->getContent(); + $fileName = basename($sandboxPath); + $localPath = $localPath ?: $fileName; + + file_put_contents($localPath, $fileContent); + + return [ + 'success' => true, + 'sandboxPath' => $sandboxPath, + 'localPath' => $localPath, + 'fileSize' => \strlen($fileContent), + 'message' => 'File downloaded successfully', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'sandboxPath' => $sandboxPath, + 'localPath' => $localPath, + 'fileSize' => 0, + 'message' => 'Failed to download file', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * List files in E2B sandbox. + * + * @param string $sandboxId E2B sandbox ID + * @param string $path Directory path (empty for root) + * @param bool $recursive List recursively + * + * @return array{ + * success: bool, + * files: array, + * path: string, + * error: string, + * } + */ + public function listFiles( + string $sandboxId, + string $path = '', + bool $recursive = false, + ): array { + try { + $params = []; + + if ($path) { + $params['path'] = $path; + } + + if ($recursive) { + $params['recursive'] = 'true'; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/sandboxes/{$sandboxId}/files", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'files' => array_map(fn ($file) => [ + 'name' => $file['name'] ?? '', + 'path' => $file['path'] ?? '', + 'size' => $file['size'] ?? 0, + 'isDirectory' => $file['is_directory'] ?? false, + 'modifiedAt' => $file['modified_at'] ?? '', + 'permissions' => $file['permissions'] ?? '', + ], $data['files'] ?? []), + 'path' => $data['path'] ?? $path, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'files' => [], + 'path' => $path, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Install Python packages in E2B. + * + * @param string $sandboxId E2B sandbox ID + * @param array $packages Packages to install + * @param string $packageManager Package manager (pip, conda) + * + * @return array{ + * success: bool, + * installedPackages: array, + * output: string, + * stderr: string, + * executionTime: float, + * error: string, + * } + */ + public function installPackage( + string $sandboxId, + array $packages, + string $packageManager = 'pip', + ): array { + try { + $packageList = implode(' ', $packages); + $code = match ($packageManager) { + 'pip' => "!pip install {$packageList}", + 'conda' => "!conda install -y {$packageList}", + default => "!pip install {$packageList}", + }; + + $requestData = [ + 'code' => $code, + 'timeout' => 120, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/sandboxes/{$sandboxId}/execute", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'installedPackages' => $packages, + 'output' => $data['output'] ?? '', + 'stderr' => $data['stderr'] ?? '', + 'executionTime' => $data['execution_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'installedPackages' => [], + 'output' => '', + 'stderr' => $e->getMessage(), + 'executionTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Run SQL queries in E2B. + * + * @param string $query SQL query to execute + * @param string $sandboxId E2B sandbox ID + * @param string $database Database name + * @param string $connectionString Database connection string + * + * @return array{ + * success: bool, + * results: array>, + * columns: array, + * rowCount: int, + * executionTime: float, + * error: string, + * } + */ + public function runSql( + string $query, + string $sandboxId, + string $database = 'sqlite', + string $connectionString = '', + ): array { + try { + $pythonCode = match ($database) { + 'sqlite' => " +import sqlite3 +import pandas as pd +import json + +# Connect to SQLite database +conn = sqlite3.connect('data.db') +df = pd.read_sql_query('{$query}', conn) +conn.close() + +# Convert results to JSON +results = df.to_dict('records') +print(json.dumps(results)) +print('COLUMNS:', json.dumps(list(df.columns))) +print('ROW_COUNT:', len(df)) +", + 'postgresql' => " +import psycopg2 +import pandas as pd +import json + +# Connect to PostgreSQL database +conn = psycopg2.connect('{$connectionString}') +df = pd.read_sql_query('{$query}', conn) +conn.close() + +# Convert results to JSON +results = df.to_dict('records') +print(json.dumps(results)) +print('COLUMNS:', json.dumps(list(df.columns))) +print('ROW_COUNT:', len(df)) +", + 'mysql' => " +import pymysql +import pandas as pd +import json + +# Connect to MySQL database +conn = pymysql.connect('{$connectionString}') +df = pd.read_sql_query('{$query}', conn) +conn.close() + +# Convert results to JSON +results = df.to_dict('records') +print(json.dumps(results)) +print('COLUMNS:', json.dumps(list(df.columns))) +print('ROW_COUNT:', len(df)) +", + default => " +import sqlite3 +import pandas as pd +import json + +# Connect to SQLite database +conn = sqlite3.connect('data.db') +df = pd.read_sql_query('{$query}', conn) +conn.close() + +# Convert results to JSON +results = df.to_dict('records') +print(json.dumps(results)) +print('COLUMNS:', json.dumps(list(df.columns))) +print('ROW_COUNT:', len(df)) +", + }; + + $requestData = [ + 'code' => $pythonCode, + 'timeout' => 60, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/sandboxes/{$sandboxId}/execute", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $output = $data['output'] ?? ''; + + // Parse the output to extract results + $lines = explode("\n", trim($output)); + $results = []; + $columns = []; + $rowCount = 0; + + foreach ($lines as $line) { + if (str_starts_with($line, 'COLUMNS:')) { + $columnsJson = substr($line, 8); + $columns = json_decode($columnsJson, true) ?? []; + } elseif (str_starts_with($line, 'ROW_COUNT:')) { + $rowCount = (int) substr($line, 10); + } elseif (!str_starts_with($line, 'COLUMNS:') && !str_starts_with($line, 'ROW_COUNT:')) { + $results = json_decode($line, true) ?? []; + } + } + + return [ + 'success' => true, + 'results' => $results, + 'columns' => $columns, + 'rowCount' => $rowCount, + 'executionTime' => $data['execution_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'results' => [], + 'columns' => [], + 'rowCount' => 0, + 'executionTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create E2B sandbox. + * + * @param string $template Sandbox template + * @param array $environment Environment variables + * @param int $timeout Sandbox timeout in seconds + * + * @return array{ + * success: bool, + * sandbox: array{ + * id: string, + * status: string, + * template: string, + * createdAt: string, + * expiresAt: string, + * environment: array, + * }, + * error: string, + * } + */ + public function createSandbox( + string $template = 'python', + array $environment = [], + int $timeout = 3600, + ): array { + try { + $requestData = [ + 'template' => $template, + 'environment' => $environment, + 'timeout' => max(300, min($timeout, 7200)), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/sandboxes", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'sandbox' => [ + 'id' => $data['id'] ?? '', + 'status' => $data['status'] ?? 'creating', + 'template' => $data['template'] ?? $template, + 'createdAt' => $data['created_at'] ?? date('c'), + 'expiresAt' => $data['expires_at'] ?? '', + 'environment' => $data['environment'] ?? $environment, + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'sandbox' => [ + 'id' => '', + 'status' => 'error', + 'template' => $template, + 'createdAt' => '', + 'expiresAt' => '', + 'environment' => $environment, + ], + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/EdenAi.php b/src/agent/src/Toolbox/Tool/EdenAi.php new file mode 100644 index 000000000..df969f4c3 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/EdenAi.php @@ -0,0 +1,780 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('edenai_text_generation', 'Tool that generates text using EdenAI')] +#[AsTool('edenai_text_to_speech', 'Tool that converts text to speech using EdenAI', method: 'textToSpeech')] +#[AsTool('edenai_speech_to_text', 'Tool that converts speech to text using EdenAI', method: 'speechToText')] +#[AsTool('edenai_image_generation', 'Tool that generates images using EdenAI', method: 'imageGeneration')] +#[AsTool('edenai_image_analysis', 'Tool that analyzes images using EdenAI', method: 'imageAnalysis')] +#[AsTool('edenai_translation', 'Tool that translates text using EdenAI', method: 'translation')] +#[AsTool('edenai_question_answer', 'Tool that answers questions using EdenAI', method: 'questionAnswer')] +#[AsTool('edenai_text_analysis', 'Tool that analyzes text using EdenAI', method: 'textAnalysis')] +final readonly class EdenAi +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.edenai.co/v2', + private array $options = [], + ) { + } + + /** + * Generate text using EdenAI. + * + * @param string $text Input text or prompt + * @param string $provider Provider (openai, anthropic, google, etc.) + * @param string $model Model name + * @param int $maxTokens Maximum tokens to generate + * @param float $temperature Temperature for generation + * @param array $parameters Additional parameters + * + * @return array{ + * success: bool, + * text: string, + * provider: string, + * model: string, + * usage: array{ + * promptTokens: int, + * completionTokens: int, + * totalTokens: int, + * }, + * cost: float, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $text, + string $provider = 'openai', + string $model = 'gpt-3.5-turbo', + int $maxTokens = 1000, + float $temperature = 0.7, + array $parameters = [], + ): array { + try { + $requestData = [ + 'providers' => $provider, + 'text' => $text, + 'max_tokens' => max(1, min($maxTokens, 4000)), + 'temperature' => max(0.0, min($temperature, 2.0)), + 'parameters' => $parameters, + ]; + + if ('gpt-3.5-turbo' !== $model) { + $requestData['settings'] = [ + $provider => [ + 'model' => $model, + ], + ]; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/text/generation", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $providerData = $data[$provider] ?? []; + + return [ + 'success' => true, + 'text' => $providerData['generated_text'] ?? '', + 'provider' => $provider, + 'model' => $model, + 'usage' => [ + 'promptTokens' => $providerData['usage']['prompt_tokens'] ?? 0, + 'completionTokens' => $providerData['usage']['completion_tokens'] ?? 0, + 'totalTokens' => $providerData['usage']['total_tokens'] ?? 0, + ], + 'cost' => $providerData['cost'] ?? 0.0, + 'processingTime' => $providerData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'text' => '', + 'provider' => $provider, + 'model' => $model, + 'usage' => ['promptTokens' => 0, 'completionTokens' => 0, 'totalTokens' => 0], + 'cost' => 0.0, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Convert text to speech using EdenAI. + * + * @param string $text Text to convert + * @param string $provider Provider (amazon, google, microsoft, etc.) + * @param string $language Language code + * @param string $voice Voice name + * @param string $outputFormat Output format (mp3, wav, ogg) + * + * @return array{ + * success: bool, + * audioUrl: string, + * provider: string, + * voice: string, + * language: string, + * duration: float, + * fileSize: int, + * cost: float, + * processingTime: float, + * error: string, + * } + */ + public function textToSpeech( + string $text, + string $provider = 'amazon', + string $language = 'en-US', + string $voice = '', + string $outputFormat = 'mp3', + ): array { + try { + $requestData = [ + 'providers' => $provider, + 'text' => $text, + 'language' => $language, + 'option' => $outputFormat, + ]; + + if ($voice) { + $requestData['settings'] = [ + $provider => [ + 'voice' => $voice, + ], + ]; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/audio/text_to_speech", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $providerData = $data[$provider] ?? []; + + return [ + 'success' => true, + 'audioUrl' => $providerData['audio_resource_url'] ?? '', + 'provider' => $provider, + 'voice' => $providerData['voice'] ?? $voice, + 'language' => $language, + 'duration' => $providerData['duration'] ?? 0.0, + 'fileSize' => $providerData['file_size'] ?? 0, + 'cost' => $providerData['cost'] ?? 0.0, + 'processingTime' => $providerData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'audioUrl' => '', + 'provider' => $provider, + 'voice' => $voice, + 'language' => $language, + 'duration' => 0.0, + 'fileSize' => 0, + 'cost' => 0.0, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Convert speech to text using EdenAI. + * + * @param string $audioUrl URL to audio file + * @param string $provider Provider (google, amazon, microsoft, etc.) + * @param string $language Language code + * @param bool $punctuation Enable punctuation + * @param bool $speakerDiarization Enable speaker diarization + * + * @return array{ + * success: bool, + * text: string, + * provider: string, + * language: string, + * confidence: float, + * duration: float, + * speakers: array, + * cost: float, + * processingTime: float, + * error: string, + * } + */ + public function speechToText( + string $audioUrl, + string $provider = 'google', + string $language = 'en-US', + bool $punctuation = true, + bool $speakerDiarization = false, + ): array { + try { + $requestData = [ + 'providers' => $provider, + 'file_url' => $audioUrl, + 'language' => $language, + 'option' => 'punctuation', + ]; + + $settings = []; + + if ($punctuation) { + $settings['punctuation'] = true; + } + + if ($speakerDiarization) { + $settings['speaker_diarization'] = true; + } + + if (!empty($settings)) { + $requestData['settings'] = [ + $provider => $settings, + ]; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/audio/speech_to_text_async", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $providerData = $data[$provider] ?? []; + + return [ + 'success' => true, + 'text' => $providerData['text'] ?? '', + 'provider' => $provider, + 'language' => $language, + 'confidence' => $providerData['confidence'] ?? 0.0, + 'duration' => $providerData['duration'] ?? 0.0, + 'speakers' => array_map(fn ($speaker) => [ + 'speaker' => $speaker['speaker'] ?? '', + 'text' => $speaker['text'] ?? '', + 'startTime' => $speaker['start_time'] ?? 0.0, + 'endTime' => $speaker['end_time'] ?? 0.0, + ], $providerData['speakers'] ?? []), + 'cost' => $providerData['cost'] ?? 0.0, + 'processingTime' => $providerData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'text' => '', + 'provider' => $provider, + 'language' => $language, + 'confidence' => 0.0, + 'duration' => 0.0, + 'speakers' => [], + 'cost' => 0.0, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Generate images using EdenAI. + * + * @param string $prompt Image generation prompt + * @param string $provider Provider (openai, stabilityai, replicate, etc.) + * @param string $resolution Image resolution + * @param int $numImages Number of images to generate + * @param string $style Image style + * + * @return array{ + * success: bool, + * images: array, + * provider: string, + * prompt: string, + * cost: float, + * processingTime: float, + * error: string, + * } + */ + public function imageGeneration( + string $prompt, + string $provider = 'openai', + string $resolution = '1024x1024', + int $numImages = 1, + string $style = '', + ): array { + try { + $requestData = [ + 'providers' => $provider, + 'text' => $prompt, + 'resolution' => $resolution, + 'num_images' => max(1, min($numImages, 4)), + ]; + + if ($style) { + $requestData['settings'] = [ + $provider => [ + 'style' => $style, + ], + ]; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/image/generation", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $providerData = $data[$provider] ?? []; + + return [ + 'success' => true, + 'images' => array_map(fn ($image) => [ + 'url' => $image['image_resource_url'] ?? '', + 'width' => $image['width'] ?? 1024, + 'height' => $image['height'] ?? 1024, + 'format' => $image['format'] ?? 'png', + ], $providerData['items'] ?? []), + 'provider' => $provider, + 'prompt' => $prompt, + 'cost' => $providerData['cost'] ?? 0.0, + 'processingTime' => $providerData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'images' => [], + 'provider' => $provider, + 'prompt' => $prompt, + 'cost' => 0.0, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze images using EdenAI. + * + * @param string $imageUrl URL to image file + * @param string $provider Provider (google, amazon, microsoft, etc.) + * @param array $features Features to extract + * + * @return array{ + * success: bool, + * analysis: array{ + * objects: array, + * faces: array, + * age: int, + * gender: string, + * boundingBox: array{ + * x: float, + * y: float, + * width: float, + * height: float, + * }, + * }>, + * text: array, + * labels: array, + * }, + * provider: string, + * cost: float, + * processingTime: float, + * error: string, + * } + */ + public function imageAnalysis( + string $imageUrl, + string $provider = 'google', + array $features = ['objects', 'faces', 'text', 'labels'], + ): array { + try { + $requestData = [ + 'providers' => $provider, + 'file_url' => $imageUrl, + 'features' => implode(',', $features), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/image/object_detection", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $providerData = $data[$provider] ?? []; + + return [ + 'success' => true, + 'analysis' => [ + 'objects' => array_map(fn ($object) => [ + 'name' => $object['name'] ?? '', + 'confidence' => $object['confidence'] ?? 0.0, + 'boundingBox' => [ + 'x' => $object['bounding_box']['x'] ?? 0.0, + 'y' => $object['bounding_box']['y'] ?? 0.0, + 'width' => $object['bounding_box']['width'] ?? 0.0, + 'height' => $object['bounding_box']['height'] ?? 0.0, + ], + ], $providerData['items'] ?? []), + 'faces' => array_map(fn ($face) => [ + 'confidence' => $face['confidence'] ?? 0.0, + 'emotions' => $face['emotions'] ?? [], + 'age' => $face['age'] ?? 0, + 'gender' => $face['gender'] ?? '', + 'boundingBox' => [ + 'x' => $face['bounding_box']['x'] ?? 0.0, + 'y' => $face['bounding_box']['y'] ?? 0.0, + 'width' => $face['bounding_box']['width'] ?? 0.0, + 'height' => $face['bounding_box']['height'] ?? 0.0, + ], + ], $providerData['faces'] ?? []), + 'text' => array_map(fn ($text) => [ + 'text' => $text['text'] ?? '', + 'confidence' => $text['confidence'] ?? 0.0, + 'boundingBox' => [ + 'x' => $text['bounding_box']['x'] ?? 0.0, + 'y' => $text['bounding_box']['y'] ?? 0.0, + 'width' => $text['bounding_box']['width'] ?? 0.0, + 'height' => $text['bounding_box']['height'] ?? 0.0, + ], + ], $providerData['text'] ?? []), + 'labels' => array_map(fn ($label) => [ + 'name' => $label['name'] ?? '', + 'confidence' => $label['confidence'] ?? 0.0, + ], $providerData['labels'] ?? []), + ], + 'provider' => $provider, + 'cost' => $providerData['cost'] ?? 0.0, + 'processingTime' => $providerData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'analysis' => [ + 'objects' => [], + 'faces' => [], + 'text' => [], + 'labels' => [], + ], + 'provider' => $provider, + 'cost' => 0.0, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Translate text using EdenAI. + * + * @param string $text Text to translate + * @param string $sourceLanguage Source language code + * @param string $targetLanguage Target language code + * @param string $provider Provider (google, amazon, microsoft, etc.) + * + * @return array{ + * success: bool, + * translatedText: string, + * sourceLanguage: string, + * targetLanguage: string, + * provider: string, + * confidence: float, + * cost: float, + * processingTime: float, + * error: string, + * } + */ + public function translation( + string $text, + string $sourceLanguage, + string $targetLanguage, + string $provider = 'google', + ): array { + try { + $requestData = [ + 'providers' => $provider, + 'text' => $text, + 'source_language' => $sourceLanguage, + 'target_language' => $targetLanguage, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/translation/automatic_translation", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $providerData = $data[$provider] ?? []; + + return [ + 'success' => true, + 'translatedText' => $providerData['text'] ?? '', + 'sourceLanguage' => $sourceLanguage, + 'targetLanguage' => $targetLanguage, + 'provider' => $provider, + 'confidence' => $providerData['confidence'] ?? 0.0, + 'cost' => $providerData['cost'] ?? 0.0, + 'processingTime' => $providerData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'translatedText' => '', + 'sourceLanguage' => $sourceLanguage, + 'targetLanguage' => $targetLanguage, + 'provider' => $provider, + 'confidence' => 0.0, + 'cost' => 0.0, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Answer questions using EdenAI. + * + * @param string $question Question to answer + * @param string $context Context for answering + * @param string $provider Provider (openai, google, anthropic, etc.) + * @param string $model Model name + * + * @return array{ + * success: bool, + * answer: string, + * confidence: float, + * provider: string, + * model: string, + * source: string, + * cost: float, + * processingTime: float, + * error: string, + * } + */ + public function questionAnswer( + string $question, + string $context, + string $provider = 'openai', + string $model = 'gpt-3.5-turbo', + ): array { + try { + $requestData = [ + 'providers' => $provider, + 'question' => $question, + 'context' => $context, + ]; + + if ('gpt-3.5-turbo' !== $model) { + $requestData['settings'] = [ + $provider => [ + 'model' => $model, + ], + ]; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/text/question_answer", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $providerData = $data[$provider] ?? []; + + return [ + 'success' => true, + 'answer' => $providerData['answer'] ?? '', + 'confidence' => $providerData['confidence'] ?? 0.0, + 'provider' => $provider, + 'model' => $model, + 'source' => $providerData['source'] ?? '', + 'cost' => $providerData['cost'] ?? 0.0, + 'processingTime' => $providerData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'answer' => '', + 'confidence' => 0.0, + 'provider' => $provider, + 'model' => $model, + 'source' => '', + 'cost' => 0.0, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze text using EdenAI. + * + * @param string $text Text to analyze + * @param string $provider Provider (google, amazon, microsoft, etc.) + * @param array $features Features to extract + * + * @return array{ + * success: bool, + * analysis: array{ + * sentiment: array{ + * general: string, + * general_score: float, + * emotions: array, + * }, + * entities: array, + * keywords: array, + * language: string, + * languageConfidence: float, + * }, + * provider: string, + * cost: float, + * processingTime: float, + * error: string, + * } + */ + public function textAnalysis( + string $text, + string $provider = 'google', + array $features = ['sentiment', 'entities', 'keywords', 'language'], + ): array { + try { + $requestData = [ + 'providers' => $provider, + 'text' => $text, + 'features' => implode(',', $features), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/text/sentiment_analysis", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $providerData = $data[$provider] ?? []; + + return [ + 'success' => true, + 'analysis' => [ + 'sentiment' => [ + 'general' => $providerData['sentiment'] ?? '', + 'general_score' => $providerData['sentiment_score'] ?? 0.0, + 'emotions' => $providerData['emotions'] ?? [], + ], + 'entities' => array_map(fn ($entity) => [ + 'text' => $entity['text'] ?? '', + 'type' => $entity['type'] ?? '', + 'confidence' => $entity['confidence'] ?? 0.0, + ], $providerData['entities'] ?? []), + 'keywords' => $providerData['keywords'] ?? [], + 'language' => $providerData['language'] ?? '', + 'languageConfidence' => $providerData['language_confidence'] ?? 0.0, + ], + 'provider' => $provider, + 'cost' => $providerData['cost'] ?? 0.0, + 'processingTime' => $providerData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'analysis' => [ + 'sentiment' => ['general' => '', 'general_score' => 0.0, 'emotions' => []], + 'entities' => [], + 'keywords' => [], + 'language' => '', + 'languageConfidence' => 0.0, + ], + 'provider' => $provider, + 'cost' => 0.0, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Elasticsearch.php b/src/agent/src/Toolbox/Tool/Elasticsearch.php new file mode 100644 index 000000000..0fa944210 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Elasticsearch.php @@ -0,0 +1,530 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('elasticsearch_search', 'Tool that searches Elasticsearch documents')] +#[AsTool('elasticsearch_get_indices', 'Tool that gets Elasticsearch indices', method: 'getIndices')] +#[AsTool('elasticsearch_create_index', 'Tool that creates Elasticsearch indices', method: 'createIndex')] +#[AsTool('elasticsearch_delete_index', 'Tool that deletes Elasticsearch indices', method: 'deleteIndex')] +#[AsTool('elasticsearch_index_document', 'Tool that indexes documents in Elasticsearch', method: 'indexDocument')] +#[AsTool('elasticsearch_get_document', 'Tool that gets documents from Elasticsearch', method: 'getDocument')] +final readonly class Elasticsearch +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $username, + #[\SensitiveParameter] private string $password, + private string $baseUrl, + private string $apiVersion = '7.17', + private array $options = [], + ) { + } + + /** + * Search Elasticsearch documents. + * + * @param string $index Index name (optional for multi-index search) + * @param array $query Search query DSL + * @param int $from Starting offset + * @param int $size Number of results to return + * @param array $sort Sort specification + * @param array $sourceFields Source fields to return + * + * @return array{ + * took: int, + * timed_out: bool, + * _shards: array{ + * total: int, + * successful: int, + * skipped: int, + * failed: int, + * }, + * hits: array{ + * total: array{ + * value: int, + * relation: string, + * }, + * max_score: float, + * hits: array, + * _version: int, + * _seq_no: int, + * _primary_term: int, + * }>, + * }, + * aggregations: array|null, + * }|string + */ + public function __invoke( + string $index = '', + array $query = [], + int $from = 0, + int $size = 10, + array $sort = [], + array $sourceFields = [], + ): array|string { + try { + $searchBody = [ + 'from' => $from, + 'size' => min(max($size, 1), 10000), + ]; + + if (!empty($query)) { + $searchBody['query'] = $query; + } + if (!empty($sort)) { + $searchBody['sort'] = $sort; + } + if (!empty($sourceFields)) { + $searchBody['_source'] = $sourceFields; + } + + $url = $index + ? "{$this->baseUrl}/{$index}/_search" + : "{$this->baseUrl}/_search"; + + $headers = ['Content-Type' => 'application/json']; + if ($this->username && $this->password) { + $headers['Authorization'] = 'Basic '.base64_encode($this->username.':'.$this->password); + } + + $response = $this->httpClient->request('POST', $url, [ + 'headers' => $headers, + 'json' => $searchBody, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error searching Elasticsearch: '.($data['error']['reason'] ?? 'Unknown error'); + } + + return [ + 'took' => $data['took'], + 'timed_out' => $data['timed_out'], + '_shards' => [ + 'total' => $data['_shards']['total'], + 'successful' => $data['_shards']['successful'], + 'skipped' => $data['_shards']['skipped'], + 'failed' => $data['_shards']['failed'], + ], + 'hits' => [ + 'total' => [ + 'value' => $data['hits']['total']['value'] ?? $data['hits']['total'], + 'relation' => $data['hits']['total']['relation'] ?? 'eq', + ], + 'max_score' => $data['hits']['max_score'], + 'hits' => array_map(fn ($hit) => [ + '_index' => $hit['_index'], + '_type' => $hit['_type'] ?? '_doc', + '_id' => $hit['_id'], + '_score' => $hit['_score'], + '_source' => $hit['_source'], + '_version' => $hit['_version'] ?? 1, + '_seq_no' => $hit['_seq_no'] ?? 0, + '_primary_term' => $hit['_primary_term'] ?? 1, + ], $data['hits']['hits']), + ], + 'aggregations' => $data['aggregations'] ?? null, + ]; + } catch (\Exception $e) { + return 'Error searching Elasticsearch: '.$e->getMessage(); + } + } + + /** + * Get Elasticsearch indices. + * + * @param string $index Index name pattern (optional) + * @param string $status Index status filter (green, yellow, red) + * @param bool $includeAliases Include aliases in response + * + * @return array>, + * mappings: array{ + * properties: array, + * }, + * settings: array{ + * index: array, + * }, + * health: string, + * status: string, + * uuid: string, + * pri: int, + * rep: int, + * docs: array{ + * count: int, + * deleted: int, + * }, + * store: array{ + * size_in_bytes: int, + * reserved_in_bytes: int, + * }, + * }> + */ + public function getIndices( + string $index = '', + string $status = '', + bool $includeAliases = true, + ): array { + try { + $params = []; + + if ($status) { + $params['status'] = $status; + } + if ($includeAliases) { + $params['include_aliases'] = 'true'; + } + + $url = $index + ? "{$this->baseUrl}/{$index}" + : "{$this->baseUrl}/_cat/indices?format=json"; + + $headers = ['Content-Type' => 'application/json']; + if ($this->username && $this->password) { + $headers['Authorization'] = 'Basic '.base64_encode($this->username.':'.$this->password); + } + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + if ($index) { + // Single index response + return [ + $index => [ + 'aliases' => $data[$index]['aliases'] ?? [], + 'mappings' => [ + 'properties' => $data[$index]['mappings']['properties'] ?? [], + ], + 'settings' => [ + 'index' => $data[$index]['settings']['index'] ?? [], + ], + 'health' => $data[$index]['health'] ?? 'unknown', + 'status' => $data[$index]['status'] ?? 'unknown', + 'uuid' => $data[$index]['uuid'] ?? '', + 'pri' => $data[$index]['pri'] ?? 0, + 'rep' => $data[$index]['rep'] ?? 0, + 'docs' => [ + 'count' => $data[$index]['docs.count'] ?? 0, + 'deleted' => $data[$index]['docs.deleted'] ?? 0, + ], + 'store' => [ + 'size_in_bytes' => $data[$index]['store.size_in_bytes'] ?? 0, + 'reserved_in_bytes' => $data[$index]['store.reserved_in_bytes'] ?? 0, + ], + ], + ]; + } + + // Multiple indices response + $result = []; + foreach ($data as $indexData) { + $indexName = $indexData['index']; + $result[$indexName] = [ + 'aliases' => [], + 'mappings' => [ + 'properties' => [], + ], + 'settings' => [ + 'index' => [], + ], + 'health' => $indexData['health'] ?? 'unknown', + 'status' => $indexData['status'] ?? 'unknown', + 'uuid' => $indexData['uuid'] ?? '', + 'pri' => (int) ($indexData['pri'] ?? 0), + 'rep' => (int) ($indexData['rep'] ?? 0), + 'docs' => [ + 'count' => (int) ($indexData['docs.count'] ?? 0), + 'deleted' => (int) ($indexData['docs.deleted'] ?? 0), + ], + 'store' => [ + 'size_in_bytes' => (int) ($indexData['store.size_in_bytes'] ?? 0), + 'reserved_in_bytes' => (int) ($indexData['store.reserved_in_bytes'] ?? 0), + ], + ]; + } + + return $result; + } catch (\Exception $e) { + return []; + } + } + + /** + * Create Elasticsearch index. + * + * @param string $index Index name + * @param array $mappings Index mappings + * @param array $settings Index settings + * @param array> $aliases Index aliases + * + * @return array{ + * acknowledged: bool, + * shards_acknowledged: bool, + * index: string, + * }|string + */ + public function createIndex( + string $index, + array $mappings = [], + array $settings = [], + array $aliases = [], + ): array|string { + try { + $body = []; + + if (!empty($mappings)) { + $body['mappings'] = $mappings; + } + if (!empty($settings)) { + $body['settings'] = $settings; + } + if (!empty($aliases)) { + $body['aliases'] = $aliases; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->username && $this->password) { + $headers['Authorization'] = 'Basic '.base64_encode($this->username.':'.$this->password); + } + + $response = $this->httpClient->request('PUT', "{$this->baseUrl}/{$index}", [ + 'headers' => $headers, + 'json' => $body, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating Elasticsearch index: '.($data['error']['reason'] ?? 'Unknown error'); + } + + return [ + 'acknowledged' => $data['acknowledged'], + 'shards_acknowledged' => $data['shards_acknowledged'] ?? false, + 'index' => $data['index'], + ]; + } catch (\Exception $e) { + return 'Error creating Elasticsearch index: '.$e->getMessage(); + } + } + + /** + * Delete Elasticsearch index. + * + * @param string $index Index name + * + * @return array{ + * acknowledged: bool, + * }|string + */ + public function deleteIndex(string $index): array|string + { + try { + $headers = ['Content-Type' => 'application/json']; + if ($this->username && $this->password) { + $headers['Authorization'] = 'Basic '.base64_encode($this->username.':'.$this->password); + } + + $response = $this->httpClient->request('DELETE', "{$this->baseUrl}/{$index}", [ + 'headers' => $headers, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error deleting Elasticsearch index: '.($data['error']['reason'] ?? 'Unknown error'); + } + + return [ + 'acknowledged' => $data['acknowledged'], + ]; + } catch (\Exception $e) { + return 'Error deleting Elasticsearch index: '.$e->getMessage(); + } + } + + /** + * Index document in Elasticsearch. + * + * @param string $index Index name + * @param string $id Document ID (optional) + * @param array $document Document body + * @param string $type Document type (default: _doc) + * @param string $routing Routing value (optional) + * @param string $pipeline Ingest pipeline (optional) + * + * @return array{ + * _index: string, + * _type: string, + * _id: string, + * _version: int, + * result: string, + * _shards: array{ + * total: int, + * successful: int, + * failed: int, + * }, + * _seq_no: int, + * _primary_term: int, + * }|string + */ + public function indexDocument( + string $index, + string $id = '', + array $document = [], + string $type = '_doc', + string $routing = '', + string $pipeline = '', + ): array|string { + try { + $params = []; + + if ($routing) { + $params['routing'] = $routing; + } + if ($pipeline) { + $params['pipeline'] = $pipeline; + } + + $url = $id + ? "{$this->baseUrl}/{$index}/{$type}/{$id}" + : "{$this->baseUrl}/{$index}/{$type}"; + + $headers = ['Content-Type' => 'application/json']; + if ($this->username && $this->password) { + $headers['Authorization'] = 'Basic '.base64_encode($this->username.':'.$this->password); + } + + $method = $id ? 'PUT' : 'POST'; + + $response = $this->httpClient->request($method, $url, [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + 'json' => $document, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error indexing document: '.($data['error']['reason'] ?? 'Unknown error'); + } + + return [ + '_index' => $data['_index'], + '_type' => $data['_type'], + '_id' => $data['_id'], + '_version' => $data['_version'], + 'result' => $data['result'], + '_shards' => [ + 'total' => $data['_shards']['total'], + 'successful' => $data['_shards']['successful'], + 'failed' => $data['_shards']['failed'], + ], + '_seq_no' => $data['_seq_no'], + '_primary_term' => $data['_primary_term'], + ]; + } catch (\Exception $e) { + return 'Error indexing document: '.$e->getMessage(); + } + } + + /** + * Get document from Elasticsearch. + * + * @param string $index Index name + * @param string $id Document ID + * @param string $type Document type (default: _doc) + * @param array $sourceFields Source fields to return + * @param string $routing Routing value (optional) + * + * @return array{ + * _index: string, + * _type: string, + * _id: string, + * _version: int, + * _seq_no: int, + * _primary_term: int, + * found: bool, + * _source: array, + * }|string + */ + public function getDocument( + string $index, + string $id, + string $type = '_doc', + array $sourceFields = [], + string $routing = '', + ): array|string { + try { + $params = []; + + if (!empty($sourceFields)) { + $params['_source'] = implode(',', $sourceFields); + } + if ($routing) { + $params['routing'] = $routing; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->username && $this->password) { + $headers['Authorization'] = 'Basic '.base64_encode($this->username.':'.$this->password); + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/{$index}/{$type}/{$id}", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting document: '.($data['error']['reason'] ?? 'Unknown error'); + } + + return [ + '_index' => $data['_index'], + '_type' => $data['_type'], + '_id' => $data['_id'], + '_version' => $data['_version'], + '_seq_no' => $data['_seq_no'], + '_primary_term' => $data['_primary_term'], + 'found' => $data['found'], + '_source' => $data['_source'] ?? [], + ]; + } catch (\Exception $e) { + return 'Error getting document: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/ElevenLabs.php b/src/agent/src/Toolbox/Tool/ElevenLabs.php new file mode 100644 index 000000000..2cb3ebcdb --- /dev/null +++ b/src/agent/src/Toolbox/Tool/ElevenLabs.php @@ -0,0 +1,302 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('eleven_labs_text2speech', 'Tool that converts text to speech using ElevenLabs API')] +#[AsTool('eleven_labs_voices', 'Tool that gets available voices from ElevenLabs', method: 'getVoices')] +#[AsTool('eleven_labs_stream', 'Tool that streams text to speech', method: 'streamSpeech')] +final readonly class ElevenLabs +{ + public const MODEL_MULTI_LINGUAL = 'eleven_multilingual_v2'; + public const MODEL_MULTI_LINGUAL_FLASH = 'eleven_flash_v2_5'; + public const MODEL_MONO_LINGUAL = 'eleven_flash_v2'; + + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $model = self::MODEL_MULTI_LINGUAL, + private string $voiceId = 'JBFqnCBsd6RMkjVDRZzb', // Default voice ID + private string $outputFormat = 'mp3_44100_128', + private array $options = [], + ) { + } + + /** + * Convert text to speech using ElevenLabs API. + * + * @param string $text Text to convert to speech + * @param string $voiceId Voice ID to use (optional, uses default if not provided) + * @param string $model Model to use (optional, uses default if not provided) + * @param string $outputDir Directory to save the audio file + * + * @return array{ + * file_path: string, + * file_size: int, + * duration: float, + * voice_used: string, + * model_used: string, + * }|string + */ + public function __invoke( + #[With(maximum: 5000)] + string $text, + string $voiceId = '', + string $model = '', + string $outputDir = '/tmp', + ): array|string { + try { + $voiceId = $voiceId ?: $this->voiceId; + $model = $model ?: $this->model; + + $response = $this->httpClient->request('POST', 'https://api.elevenlabs.io/v1/text-to-speech/'.$voiceId, [ + 'headers' => [ + 'Accept' => 'audio/mpeg', + 'Content-Type' => 'application/json', + 'xi-api-key' => $this->apiKey, + ], + 'json' => [ + 'text' => $text, + 'model_id' => $model, + 'voice_settings' => [ + 'stability' => 0.5, + 'similarity_boost' => 0.5, + ], + ], + 'buffer' => false, + ]); + + if (200 !== $response->getStatusCode()) { + return 'Error: Failed to generate speech - '.$response->getContent(false); + } + + // Save the audio file + $filename = 'elevenlabs_'.uniqid().'.mp3'; + $filePath = rtrim($outputDir, '/').'/'.$filename; + + file_put_contents($filePath, $response->getContent()); + + $fileSize = filesize($filePath); + + return [ + 'file_path' => $filePath, + 'file_size' => $fileSize, + 'duration' => $this->estimateDuration($text), + 'voice_used' => $voiceId, + 'model_used' => $model, + ]; + } catch (\Exception $e) { + return 'Error converting text to speech: '.$e->getMessage(); + } + } + + /** + * Get available voices from ElevenLabs. + * + * @return array, + * preview_url: string, + * available_for_tiers: array, + * settings: array, + * }> + */ + public function getVoices(): array + { + try { + $response = $this->httpClient->request('GET', 'https://api.elevenlabs.io/v1/voices', [ + 'headers' => [ + 'Accept' => 'application/json', + 'xi-api-key' => $this->apiKey, + ], + ]); + + $data = $response->toArray(); + + if (!isset($data['voices'])) { + return []; + } + + $results = []; + foreach ($data['voices'] as $voice) { + $results[] = [ + 'voice_id' => $voice['voice_id'], + 'name' => $voice['name'], + 'category' => $voice['category'], + 'description' => $voice['description'] ?? '', + 'labels' => $voice['labels'] ?? [], + 'preview_url' => $voice['preview_url'] ?? '', + 'available_for_tiers' => $voice['available_for_tiers'] ?? [], + 'settings' => $voice['settings'] ?? [], + ]; + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'voice_id' => 'error', + 'name' => 'Error', + 'category' => '', + 'description' => 'Unable to get voices: '.$e->getMessage(), + 'labels' => [], + 'preview_url' => '', + 'available_for_tiers' => [], + 'settings' => [], + ], + ]; + } + } + + /** + * Stream text to speech (returns streaming response). + * + * @param string $text Text to convert to speech + * @param string $voiceId Voice ID to use (optional) + * @param string $model Model to use (optional) + */ + public function streamSpeech( + #[With(maximum: 5000)] + string $text, + string $voiceId = '', + string $model = '', + ): string { + try { + $voiceId = $voiceId ?: $this->voiceId; + $model = $model ?: $this->model; + + $response = $this->httpClient->request('POST', 'https://api.elevenlabs.io/v1/text-to-speech/'.$voiceId.'/stream', [ + 'headers' => [ + 'Accept' => 'audio/mpeg', + 'Content-Type' => 'application/json', + 'xi-api-key' => $this->apiKey, + ], + 'json' => [ + 'text' => $text, + 'model_id' => $model, + 'voice_settings' => [ + 'stability' => 0.5, + 'similarity_boost' => 0.5, + ], + ], + ]); + + if (200 !== $response->getStatusCode()) { + return 'Error: Failed to stream speech - '.$response->getContent(false); + } + + return 'Speech streaming started successfully. Audio data available in response.'; + } catch (\Exception $e) { + return 'Error streaming speech: '.$e->getMessage(); + } + } + + /** + * Get voice settings for a specific voice. + * + * @param string $voiceId Voice ID + * + * @return array{ + * stability: float, + * similarity_boost: float, + * style: float, + * use_speaker_boost: bool, + * }|string + */ + public function getVoiceSettings(string $voiceId): array|string + { + try { + $response = $this->httpClient->request('GET', "https://api.elevenlabs.io/v1/voices/{$voiceId}/settings", [ + 'headers' => [ + 'Accept' => 'application/json', + 'xi-api-key' => $this->apiKey, + ], + ]); + + $data = $response->toArray(); + + return [ + 'stability' => $data['stability'], + 'similarity_boost' => $data['similarity_boost'], + 'style' => $data['style'] ?? 0.0, + 'use_speaker_boost' => $data['use_speaker_boost'] ?? true, + ]; + } catch (\Exception $e) { + return 'Error getting voice settings: '.$e->getMessage(); + } + } + + /** + * Update voice settings for a specific voice. + * + * @param string $voiceId Voice ID + * @param float $stability Stability setting (0.0 to 1.0) + * @param float $similarity Similarity boost setting (0.0 to 1.0) + * @param float $style Style setting (0.0 to 1.0) + * @param bool $speakerBoost Use speaker boost + */ + public function updateVoiceSettings( + string $voiceId, + float $stability = 0.5, + float $similarity = 0.5, + float $style = 0.0, + bool $speakerBoost = true, + ): string { + try { + $response = $this->httpClient->request('POST', "https://api.elevenlabs.io/v1/voices/{$voiceId}/settings", [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'xi-api-key' => $this->apiKey, + ], + 'json' => [ + 'stability' => $stability, + 'similarity_boost' => $similarity, + 'style' => $style, + 'use_speaker_boost' => $speakerBoost, + ], + ]); + + if (200 === $response->getStatusCode()) { + return "Voice settings updated successfully for voice {$voiceId}"; + } else { + return 'Failed to update voice settings'; + } + } catch (\Exception $e) { + return 'Error updating voice settings: '.$e->getMessage(); + } + } + + /** + * Estimate speech duration based on text length. + */ + private function estimateDuration(string $text): float + { + // Rough estimation: average reading speed is about 150-200 words per minute + $wordCount = str_word_count($text); + $minutes = $wordCount / 175; // Using 175 WPM as average + + return round($minutes * 60, 2); // Convert to seconds + } +} diff --git a/src/agent/src/Toolbox/Tool/Facebook.php b/src/agent/src/Toolbox/Tool/Facebook.php new file mode 100644 index 000000000..d4ac1548a --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Facebook.php @@ -0,0 +1,490 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('facebook_post_to_page', 'Tool that posts content to Facebook pages')] +#[AsTool('facebook_get_page_posts', 'Tool that retrieves posts from Facebook pages', method: 'getPagePosts')] +#[AsTool('facebook_get_page_info', 'Tool that gets Facebook page information', method: 'getPageInfo')] +#[AsTool('facebook_search_pages', 'Tool that searches for Facebook pages', method: 'searchPages')] +#[AsTool('facebook_get_insights', 'Tool that gets Facebook page insights', method: 'getInsights')] +final readonly class Facebook +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private string $apiVersion = 'v18.0', + private array $options = [], + ) { + } + + /** + * Post content to a Facebook page. + * + * @param string $pageId Facebook page ID + * @param string $message Post message content + * @param string $link Optional link to share + * @param string $pictureUrl Optional picture URL + * @param string $name Optional link name + * @param string $caption Optional link caption + * @param string $description Optional link description + * + * @return array{ + * id: string, + * post_id: string, + * }|string + */ + public function __invoke( + string $pageId, + #[With(maximum: 63206)] + string $message = '', + string $link = '', + string $pictureUrl = '', + string $name = '', + string $caption = '', + string $description = '', + ): array|string { + try { + $postData = []; + + if ($message) { + $postData['message'] = $message; + } + + if ($link) { + $postData['link'] = $link; + } + + if ($pictureUrl) { + $postData['picture'] = $pictureUrl; + } + + if ($name) { + $postData['name'] = $name; + } + + if ($caption) { + $postData['caption'] = $caption; + } + + if ($description) { + $postData['description'] = $description; + } + + if (empty($postData)) { + return 'Error: At least one field (message, link, picture) must be provided'; + } + + $response = $this->httpClient->request('POST', "https://graph.facebook.com/{$this->apiVersion}/{$pageId}/feed", [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => array_merge($this->options, $postData, [ + 'access_token' => $this->accessToken, + ]), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error posting to Facebook: '.$data['error']['message']; + } + + return [ + 'id' => $data['id'], + 'post_id' => $data['id'], + ]; + } catch (\Exception $e) { + return 'Error posting to Facebook: '.$e->getMessage(); + } + } + + /** + * Get posts from a Facebook page. + * + * @param string $pageId Facebook page ID + * @param int $limit Number of posts to retrieve (1-100) + * @param string $since Get posts since this date (YYYY-MM-DD) + * @param string $until Get posts until this date (YYYY-MM-DD) + * + * @return array, summary: array{total_count: int}}, + * }> + */ + public function getPagePosts( + string $pageId, + int $limit = 25, + string $since = '', + string $until = '', + ): array { + try { + $fields = 'id,message,created_time,updated_time,type,link,picture,full_picture,permalink_url,shares,reactions.summary(true).limit(0)'; + + $params = [ + 'fields' => $fields, + 'limit' => min(max($limit, 1), 100), + 'access_token' => $this->accessToken, + ]; + + if ($since) { + $params['since'] = strtotime($since); + } + if ($until) { + $params['until'] = strtotime($until); + } + + $response = $this->httpClient->request('GET', "https://graph.facebook.com/{$this->apiVersion}/{$pageId}/posts", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return []; + } + + $posts = []; + foreach ($data['data'] as $post) { + $posts[] = [ + 'id' => $post['id'], + 'message' => $post['message'] ?? '', + 'created_time' => $post['created_time'], + 'updated_time' => $post['updated_time'], + 'type' => $post['type'] ?? 'status', + 'link' => $post['link'] ?? '', + 'picture' => $post['picture'] ?? '', + 'full_picture' => $post['full_picture'] ?? '', + 'permalink_url' => $post['permalink_url'] ?? '', + 'shares' => [ + 'count' => $post['shares']['count'] ?? 0, + ], + 'reactions' => [ + 'data' => array_map(fn ($reaction) => [ + 'name' => $reaction['name'], + 'id' => $reaction['id'], + 'type' => $reaction['type'], + ], $post['reactions']['data'] ?? []), + 'summary' => [ + 'total_count' => $post['reactions']['summary']['total_count'] ?? 0, + ], + ], + ]; + } + + return $posts; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Facebook page information. + * + * @param string $pageId Facebook page ID + * + * @return array{ + * id: string, + * name: string, + * username: string, + * about: string, + * category: string, + * description: string, + * fan_count: int, + * followers_count: int, + * link: string, + * website: string, + * phone: string, + * emails: array, + * location: array{ + * street: string, + * city: string, + * state: string, + * country: string, + * zip: string, + * latitude: float, + * longitude: float, + * }, + * picture: array{data: array{url: string, is_silhouette: bool}}, + * cover: array{source: string, offset_y: int, offset_x: int}, + * hours: array, + * verification_status: string, + * }|string + */ + public function getPageInfo(string $pageId): array|string + { + try { + $fields = 'id,name,username,about,category,description,fan_count,followers_count,link,website,phone,emails,location,picture,cover,hours,verification_status'; + + $response = $this->httpClient->request('GET', "https://graph.facebook.com/{$this->apiVersion}/{$pageId}", [ + 'query' => array_merge($this->options, [ + 'fields' => $fields, + 'access_token' => $this->accessToken, + ]), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting page info: '.$data['error']['message']; + } + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'username' => $data['username'] ?? '', + 'about' => $data['about'] ?? '', + 'category' => $data['category'] ?? '', + 'description' => $data['description'] ?? '', + 'fan_count' => $data['fan_count'] ?? 0, + 'followers_count' => $data['followers_count'] ?? 0, + 'link' => $data['link'] ?? '', + 'website' => $data['website'] ?? '', + 'phone' => $data['phone'] ?? '', + 'emails' => $data['emails'] ?? [], + 'location' => [ + 'street' => $data['location']['street'] ?? '', + 'city' => $data['location']['city'] ?? '', + 'state' => $data['location']['state'] ?? '', + 'country' => $data['location']['country'] ?? '', + 'zip' => $data['location']['zip'] ?? '', + 'latitude' => $data['location']['latitude'] ?? 0.0, + 'longitude' => $data['location']['longitude'] ?? 0.0, + ], + 'picture' => [ + 'data' => [ + 'url' => $data['picture']['data']['url'] ?? '', + 'is_silhouette' => $data['picture']['data']['is_silhouette'] ?? false, + ], + ], + 'cover' => [ + 'source' => $data['cover']['source'] ?? '', + 'offset_y' => $data['cover']['offset_y'] ?? 0, + 'offset_x' => $data['cover']['offset_x'] ?? 0, + ], + 'hours' => $data['hours'] ?? [], + 'verification_status' => $data['verification_status'] ?? '', + ]; + } catch (\Exception $e) { + return 'Error getting page info: '.$e->getMessage(); + } + } + + /** + * Search for Facebook pages. + * + * @param string $query Search query + * @param string $type Type of search (page, place, event, user) + * @param int $limit Number of results (1-100) + * + * @return array + */ + public function searchPages( + #[With(maximum: 500)] + string $query, + string $type = 'page', + int $limit = 25, + ): array { + try { + $fields = 'id,name,category,fan_count,link,picture'; + + $response = $this->httpClient->request('GET', "https://graph.facebook.com/{$this->apiVersion}/search", [ + 'query' => array_merge($this->options, [ + 'q' => $query, + 'type' => $type, + 'fields' => $fields, + 'limit' => min(max($limit, 1), 100), + 'access_token' => $this->accessToken, + ]), + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return []; + } + + $pages = []; + foreach ($data['data'] as $page) { + $pages[] = [ + 'id' => $page['id'], + 'name' => $page['name'], + 'category' => $page['category'] ?? '', + 'fan_count' => $page['fan_count'] ?? 0, + 'link' => $page['link'] ?? '', + 'picture' => [ + 'data' => [ + 'url' => $page['picture']['data']['url'] ?? '', + ], + ], + ]; + } + + return $pages; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Facebook page insights. + * + * @param string $pageId Facebook page ID + * @param string $metric Insight metric (page_impressions, page_reach, page_engaged_users, etc.) + * @param string $period Time period (day, week, days_28) + * @param string $since Start date (YYYY-MM-DD) + * @param string $until End date (YYYY-MM-DD) + * + * @return array{ + * data: array, + * }>, + * }|string + */ + public function getInsights( + string $pageId, + string $metric = 'page_impressions', + string $period = 'day', + string $since = '', + string $until = '', + ): array|string { + try { + $params = [ + 'metric' => $metric, + 'period' => $period, + 'access_token' => $this->accessToken, + ]; + + if ($since) { + $params['since'] = strtotime($since); + } + if ($until) { + $params['until'] = strtotime($until); + } + + $response = $this->httpClient->request('GET', "https://graph.facebook.com/{$this->apiVersion}/{$pageId}/insights", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting insights: '.$data['error']['message']; + } + + return [ + 'data' => array_map(fn ($insight) => [ + 'name' => $insight['name'], + 'period' => $insight['period'], + 'values' => array_map(fn ($value) => [ + 'value' => $value['value'], + 'end_time' => $value['end_time'], + ], $insight['values']), + ], $data['data']), + ]; + } catch (\Exception $e) { + return 'Error getting insights: '.$e->getMessage(); + } + } + + /** + * Upload photo to Facebook page. + * + * @param string $pageId Facebook page ID + * @param string $photoPath Path to the photo file + * @param string $message Optional message with the photo + * @param string $caption Optional photo caption + * + * @return array{ + * id: string, + * post_id: string, + * }|string + */ + public function uploadPhoto( + string $pageId, + string $photoPath, + string $message = '', + string $caption = '', + ): array|string { + try { + if (!file_exists($photoPath)) { + return 'Error: Photo file does not exist'; + } + + $photoData = file_get_contents($photoPath); + $photoBase64 = base64_encode($photoData); + + $postData = [ + 'source' => $photoBase64, + 'access_token' => $this->accessToken, + ]; + + if ($message) { + $postData['message'] = $message; + } + + if ($caption) { + $postData['caption'] = $caption; + } + + $response = $this->httpClient->request('POST', "https://graph.facebook.com/{$this->apiVersion}/{$pageId}/photos", [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => $postData, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error uploading photo: '.$data['error']['message']; + } + + return [ + 'id' => $data['id'], + 'post_id' => $data['id'], + ]; + } catch (\Exception $e) { + return 'Error uploading photo: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/FewShot.php b/src/agent/src/Toolbox/Tool/FewShot.php new file mode 100644 index 000000000..384198af4 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/FewShot.php @@ -0,0 +1,758 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('few_shot_learn', 'Tool that performs few-shot learning with examples')] +#[AsTool('few_shot_classify', 'Tool that classifies data using few-shot examples', method: 'classify')] +#[AsTool('few_shot_generate', 'Tool that generates content using few-shot examples', method: 'generate')] +#[AsTool('few_shot_extract', 'Tool that extracts information using few-shot examples', method: 'extract')] +#[AsTool('few_shot_transform', 'Tool that transforms data using few-shot examples', method: 'transform')] +#[AsTool('few_shot_compare', 'Tool that compares data using few-shot examples', method: 'compare')] +#[AsTool('few_shot_similarity', 'Tool that finds similar data using few-shot examples', method: 'similarity')] +#[AsTool('few_shot_cluster', 'Tool that clusters data using few-shot examples', method: 'cluster')] +final readonly class FewShot +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.fewshot.ai/v1', + private array $options = [], + ) { + } + + /** + * Perform few-shot learning with examples. + * + * @param string $task Task description + * @param array $examples Few-shot examples + * @param string $input Input to process + * @param string $model Model to use + * @param array $parameters Additional parameters + * + * @return array{ + * success: bool, + * result: array{ + * input: string, + * output: string, + * confidence: float, + * reasoning: string, + * examples_used: int, + * model: string, + * }, + * task: string, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $task, + array $examples, + string $input, + string $model = 'gpt-3.5-turbo', + array $parameters = [], + ): array { + try { + $requestData = [ + 'task' => $task, + 'examples' => $examples, + 'input' => $input, + 'model' => $model, + 'parameters' => $parameters, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/learn", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $result = $data['result'] ?? []; + + return [ + 'success' => true, + 'result' => [ + 'input' => $input, + 'output' => $result['output'] ?? '', + 'confidence' => $result['confidence'] ?? 0.0, + 'reasoning' => $result['reasoning'] ?? '', + 'examples_used' => \count($examples), + 'model' => $model, + ], + 'task' => $task, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'result' => [ + 'input' => $input, + 'output' => '', + 'confidence' => 0.0, + 'reasoning' => '', + 'examples_used' => \count($examples), + 'model' => $model, + ], + 'task' => $task, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Classify data using few-shot examples. + * + * @param string $input Input to classify + * @param array $categories Categories to classify into + * @param array $examples Few-shot classification examples + * @param string $model Model to use + * + * @return array{ + * success: bool, + * classification: array{ + * input: string, + * category: string, + * confidence: float, + * probabilities: array, + * reasoning: string, + * }, + * categories: array, + * examples_used: int, + * processingTime: float, + * error: string, + * } + */ + public function classify( + string $input, + array $categories, + array $examples, + string $model = 'gpt-3.5-turbo', + ): array { + try { + $requestData = [ + 'input' => $input, + 'categories' => $categories, + 'examples' => $examples, + 'model' => $model, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/classify", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $classification = $data['classification'] ?? []; + + return [ + 'success' => true, + 'classification' => [ + 'input' => $input, + 'category' => $classification['category'] ?? '', + 'confidence' => $classification['confidence'] ?? 0.0, + 'probabilities' => $classification['probabilities'] ?? [], + 'reasoning' => $classification['reasoning'] ?? '', + ], + 'categories' => $categories, + 'examples_used' => \count($examples), + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'classification' => [ + 'input' => $input, + 'category' => '', + 'confidence' => 0.0, + 'probabilities' => [], + 'reasoning' => '', + ], + 'categories' => $categories, + 'examples_used' => \count($examples), + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Generate content using few-shot examples. + * + * @param string $prompt Generation prompt + * @param array $examples Few-shot generation examples + * @param string $model Model to use + * @param int $maxTokens Maximum tokens to generate + * @param float $temperature Generation temperature + * + * @return array{ + * success: bool, + * generation: array{ + * prompt: string, + * output: string, + * confidence: float, + * style: string, + * tokens_used: int, + * examples_used: int, + * }, + * model: string, + * processingTime: float, + * error: string, + * } + */ + public function generate( + string $prompt, + array $examples, + string $model = 'gpt-3.5-turbo', + int $maxTokens = 1000, + float $temperature = 0.7, + ): array { + try { + $requestData = [ + 'prompt' => $prompt, + 'examples' => $examples, + 'model' => $model, + 'max_tokens' => $maxTokens, + 'temperature' => $temperature, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/generate", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $generation = $data['generation'] ?? []; + + return [ + 'success' => true, + 'generation' => [ + 'prompt' => $prompt, + 'output' => $generation['output'] ?? '', + 'confidence' => $generation['confidence'] ?? 0.0, + 'style' => $generation['style'] ?? '', + 'tokens_used' => $generation['tokens_used'] ?? 0, + 'examples_used' => \count($examples), + ], + 'model' => $model, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'generation' => [ + 'prompt' => $prompt, + 'output' => '', + 'confidence' => 0.0, + 'style' => '', + 'tokens_used' => 0, + 'examples_used' => \count($examples), + ], + 'model' => $model, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Extract information using few-shot examples. + * + * @param string $text Text to extract from + * @param string $extractionType Type of extraction (entities, relationships, facts, etc.) + * @param array, + * format?: string, + * }> $examples Few-shot extraction examples + * @param string $model Model to use + * + * @return array{ + * success: bool, + * extraction: array{ + * text: string, + * extracted: array, + * confidence: float, + * extraction_type: string, + * format: string, + * examples_used: int, + * }, + * model: string, + * processingTime: float, + * error: string, + * } + */ + public function extract( + string $text, + string $extractionType, + array $examples, + string $model = 'gpt-3.5-turbo', + ): array { + try { + $requestData = [ + 'text' => $text, + 'extraction_type' => $extractionType, + 'examples' => $examples, + 'model' => $model, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/extract", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $extraction = $data['extraction'] ?? []; + + return [ + 'success' => true, + 'extraction' => [ + 'text' => $text, + 'extracted' => $extraction['extracted'] ?? [], + 'confidence' => $extraction['confidence'] ?? 0.0, + 'extraction_type' => $extractionType, + 'format' => $extraction['format'] ?? 'json', + 'examples_used' => \count($examples), + ], + 'model' => $model, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'extraction' => [ + 'text' => $text, + 'extracted' => [], + 'confidence' => 0.0, + 'extraction_type' => $extractionType, + 'format' => 'json', + 'examples_used' => \count($examples), + ], + 'model' => $model, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Transform data using few-shot examples. + * + * @param string $input Input to transform + * @param string $transformationType Type of transformation + * @param array $examples Few-shot transformation examples + * @param string $model Model to use + * + * @return array{ + * success: bool, + * transformation: array{ + * input: string, + * output: string, + * transformation_type: string, + * confidence: float, + * reasoning: string, + * examples_used: int, + * }, + * model: string, + * processingTime: float, + * error: string, + * } + */ + public function transform( + string $input, + string $transformationType, + array $examples, + string $model = 'gpt-3.5-turbo', + ): array { + try { + $requestData = [ + 'input' => $input, + 'transformation_type' => $transformationType, + 'examples' => $examples, + 'model' => $model, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/transform", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $transformation = $data['transformation'] ?? []; + + return [ + 'success' => true, + 'transformation' => [ + 'input' => $input, + 'output' => $transformation['output'] ?? '', + 'transformation_type' => $transformationType, + 'confidence' => $transformation['confidence'] ?? 0.0, + 'reasoning' => $transformation['reasoning'] ?? '', + 'examples_used' => \count($examples), + ], + 'model' => $model, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'transformation' => [ + 'input' => $input, + 'output' => '', + 'transformation_type' => $transformationType, + 'confidence' => 0.0, + 'reasoning' => '', + 'examples_used' => \count($examples), + ], + 'model' => $model, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Compare data using few-shot examples. + * + * @param string $item1 First item to compare + * @param string $item2 Second item to compare + * @param string $comparisonType Type of comparison + * @param array, + * relationship: string, + * }, + * }> $examples Few-shot comparison examples + * @param string $model Model to use + * + * @return array{ + * success: bool, + * comparison: array{ + * item1: string, + * item2: string, + * similarity: float, + * differences: array, + * relationship: string, + * confidence: float, + * reasoning: string, + * examples_used: int, + * }, + * comparisonType: string, + * processingTime: float, + * error: string, + * } + */ + public function compare( + string $item1, + string $item2, + string $comparisonType, + array $examples, + string $model = 'gpt-3.5-turbo', + ): array { + try { + $requestData = [ + 'item1' => $item1, + 'item2' => $item2, + 'comparison_type' => $comparisonType, + 'examples' => $examples, + 'model' => $model, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/compare", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $comparison = $data['comparison'] ?? []; + + return [ + 'success' => true, + 'comparison' => [ + 'item1' => $item1, + 'item2' => $item2, + 'similarity' => $comparison['similarity'] ?? 0.0, + 'differences' => $comparison['differences'] ?? [], + 'relationship' => $comparison['relationship'] ?? '', + 'confidence' => $comparison['confidence'] ?? 0.0, + 'reasoning' => $comparison['reasoning'] ?? '', + 'examples_used' => \count($examples), + ], + 'comparisonType' => $comparisonType, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'comparison' => [ + 'item1' => $item1, + 'item2' => $item2, + 'similarity' => 0.0, + 'differences' => [], + 'relationship' => '', + 'confidence' => 0.0, + 'reasoning' => '', + 'examples_used' => \count($examples), + ], + 'comparisonType' => $comparisonType, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Find similar data using few-shot examples. + * + * @param string $query Query to find similarities for + * @param array $candidates Candidate items to compare against + * @param array, + * similarities: array, + * top_matches: array, + * }> $examples Few-shot similarity examples + * @param string $model Model to use + * @param int $topK Number of top similar items to return + * + * @return array{ + * success: bool, + * similarity: array{ + * query: string, + * similarities: array, + * top_matches: array, + * confidence: float, + * examples_used: int, + * }, + * candidates: array, + * topK: int, + * processingTime: float, + * error: string, + * } + */ + public function similarity( + string $query, + array $candidates, + array $examples, + string $model = 'gpt-3.5-turbo', + int $topK = 5, + ): array { + try { + $requestData = [ + 'query' => $query, + 'candidates' => $candidates, + 'examples' => $examples, + 'model' => $model, + 'top_k' => $topK, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/similarity", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $similarity = $data['similarity'] ?? []; + + return [ + 'success' => true, + 'similarity' => [ + 'query' => $query, + 'similarities' => $similarity['similarities'] ?? [], + 'top_matches' => array_map(fn ($match) => [ + 'item' => $match['item'] ?? '', + 'similarity' => $match['similarity'] ?? 0.0, + 'reasoning' => $match['reasoning'] ?? '', + ], $similarity['top_matches'] ?? []), + 'confidence' => $similarity['confidence'] ?? 0.0, + 'examples_used' => \count($examples), + ], + 'candidates' => $candidates, + 'topK' => $topK, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'similarity' => [ + 'query' => $query, + 'similarities' => [], + 'top_matches' => [], + 'confidence' => 0.0, + 'examples_used' => \count($examples), + ], + 'candidates' => $candidates, + 'topK' => $topK, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Cluster data using few-shot examples. + * + * @param array $items Items to cluster + * @param array, + * clusters: array, + * centroid: string, + * description: string, + * }>, + * }> $examples Few-shot clustering examples + * @param string $model Model to use + * @param int $numClusters Number of clusters to create + * + * @return array{ + * success: bool, + * clustering: array{ + * items: array, + * clusters: array, + * centroid: string, + * description: string, + * confidence: float, + * }>, + * silhouette_score: float, + * examples_used: int, + * }, + * numClusters: int, + * processingTime: float, + * error: string, + * } + */ + public function cluster( + array $items, + array $examples, + string $model = 'gpt-3.5-turbo', + int $numClusters = 3, + ): array { + try { + $requestData = [ + 'items' => $items, + 'examples' => $examples, + 'model' => $model, + 'num_clusters' => $numClusters, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/cluster", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $clustering = $data['clustering'] ?? []; + + return [ + 'success' => true, + 'clustering' => [ + 'items' => $items, + 'clusters' => array_map(fn ($cluster) => [ + 'id' => $cluster['id'] ?? '', + 'items' => $cluster['items'] ?? [], + 'centroid' => $cluster['centroid'] ?? '', + 'description' => $cluster['description'] ?? '', + 'confidence' => $cluster['confidence'] ?? 0.0, + ], $clustering['clusters'] ?? []), + 'silhouette_score' => $clustering['silhouette_score'] ?? 0.0, + 'examples_used' => \count($examples), + ], + 'numClusters' => $numClusters, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'clustering' => [ + 'items' => $items, + 'clusters' => [], + 'silhouette_score' => 0.0, + 'examples_used' => \count($examples), + ], + 'numClusters' => $numClusters, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Figma.php b/src/agent/src/Toolbox/Tool/Figma.php new file mode 100644 index 000000000..28efceda2 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Figma.php @@ -0,0 +1,448 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('figma_get_file', 'Tool that gets Figma files')] +#[AsTool('figma_get_file_nodes', 'Tool that gets Figma file nodes', method: 'getFileNodes')] +#[AsTool('figma_get_images', 'Tool that gets Figma images', method: 'getImages')] +#[AsTool('figma_get_comments', 'Tool that gets Figma comments', method: 'getComments')] +#[AsTool('figma_get_team_projects', 'Tool that gets Figma team projects', method: 'getTeamProjects')] +#[AsTool('figma_get_team_files', 'Tool that gets Figma team files', method: 'getTeamFiles')] +final readonly class Figma +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private string $apiVersion = 'v1', + private array $options = [], + ) { + } + + /** + * Get Figma file. + * + * @param string $fileKey Figma file key + * @param string $version File version + * @param array $ids Specific node IDs to retrieve + * @param int $depth Depth of children to retrieve + * @param string $geometry Geometry type (paths, bounds) + * @param string $pluginData Plugin data to include + * + * @return array{ + * document: array{ + * id: string, + * name: string, + * type: string, + * visible: bool, + * children: array>, + * }, + * components: array, + * }>, + * componentSets: array, + * }>, + * styles: array, + * name: string, + * lastModified: string, + * thumbnailUrl: string, + * version: string, + * role: string, + * editorType: string, + * linkAccess: string, + * }|string + */ + public function __invoke( + string $fileKey, + string $version = '', + array $ids = [], + int $depth = 1, + string $geometry = 'paths', + string $pluginData = '', + ): array|string { + try { + $params = [ + 'depth' => max($depth, 1), + 'geometry' => $geometry, + ]; + + if ($version) { + $params['version'] = $version; + } + if (!empty($ids)) { + $params['ids'] = implode(',', $ids); + } + if ($pluginData) { + $params['plugin_data'] = $pluginData; + } + + $response = $this->httpClient->request('GET', "https://api.figma.com/{$this->apiVersion}/files/{$fileKey}", [ + 'headers' => [ + 'X-Figma-Token' => $this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && 200 !== $data['status']) { + return 'Error getting file: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'document' => $data['document'], + 'components' => $data['components'] ?? [], + 'componentSets' => $data['componentSets'] ?? [], + 'styles' => $data['styles'] ?? [], + 'name' => $data['name'], + 'lastModified' => $data['lastModified'], + 'thumbnailUrl' => $data['thumbnailUrl'], + 'version' => $data['version'], + 'role' => $data['role'], + 'editorType' => $data['editorType'], + 'linkAccess' => $data['linkAccess'], + ]; + } catch (\Exception $e) { + return 'Error getting file: '.$e->getMessage(); + } + } + + /** + * Get Figma file nodes. + * + * @param string $fileKey Figma file key + * @param array $ids Node IDs to retrieve + * @param int $depth Depth of children to retrieve + * @param string $geometry Geometry type (paths, bounds) + * @param string $pluginData Plugin data to include + * + * @return array{ + * nodes: array, + * components: array, + * componentSets: array, + * styles: array, + * }>, + * lastModified: string, + * name: string, + * role: string, + * thumbnailUrl: string, + * version: string, + * }|string + */ + public function getFileNodes( + string $fileKey, + array $ids, + int $depth = 1, + string $geometry = 'paths', + string $pluginData = '', + ): array|string { + try { + if (empty($ids)) { + return 'Error: Node IDs are required'; + } + + $params = [ + 'ids' => implode(',', $ids), + 'depth' => max($depth, 1), + 'geometry' => $geometry, + ]; + + if ($pluginData) { + $params['plugin_data'] = $pluginData; + } + + $response = $this->httpClient->request('GET', "https://api.figma.com/{$this->apiVersion}/files/{$fileKey}/nodes", [ + 'headers' => [ + 'X-Figma-Token' => $this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && 200 !== $data['status']) { + return 'Error getting file nodes: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'nodes' => $data['nodes'], + 'lastModified' => $data['lastModified'], + 'name' => $data['name'], + 'role' => $data['role'], + 'thumbnailUrl' => $data['thumbnailUrl'], + 'version' => $data['version'], + ]; + } catch (\Exception $e) { + return 'Error getting file nodes: '.$e->getMessage(); + } + } + + /** + * Get Figma images. + * + * @param string $fileKey Figma file key + * @param array $ids Node IDs to get images for + * @param string $format Image format (jpg, png, svg, pdf) + * @param string $scale Image scale (1, 2, 4, 8) + * @param string $svgOutline SVG outline mode (full, simplified) + * @param string $svgId SVG node ID + * @param bool $useAbsoluteBounds Use absolute bounds + * @param string $version File version + * + * @return array{ + * images: array, + * status: int, + * error: bool, + * }|string + */ + public function getImages( + string $fileKey, + array $ids, + string $format = 'png', + string $scale = '1', + string $svgOutline = 'full', + string $svgId = '', + bool $useAbsoluteBounds = false, + string $version = '', + ): array|string { + try { + if (empty($ids)) { + return 'Error: Node IDs are required'; + } + + $params = [ + 'ids' => implode(',', $ids), + 'format' => $format, + 'scale' => $scale, + 'svg_outline' => $svgOutline, + 'use_absolute_bounds' => $useAbsoluteBounds, + ]; + + if ($svgId) { + $params['svg_id'] = $svgId; + } + if ($version) { + $params['version'] = $version; + } + + $response = $this->httpClient->request('GET', "https://api.figma.com/{$this->apiVersion}/images/{$fileKey}", [ + 'headers' => [ + 'X-Figma-Token' => $this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && 200 !== $data['status']) { + return 'Error getting images: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'images' => $data['images'] ?? [], + 'status' => $data['status'] ?? 200, + 'error' => $data['error'] ?? false, + ]; + } catch (\Exception $e) { + return 'Error getting images: '.$e->getMessage(); + } + } + + /** + * Get Figma comments. + * + * @param string $fileKey Figma file key + * + * @return array|string + */ + public function getComments(string $fileKey): array|string + { + try { + $response = $this->httpClient->request('GET', "https://api.figma.com/{$this->apiVersion}/files/{$fileKey}/comments", [ + 'headers' => [ + 'X-Figma-Token' => $this->accessToken, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && 200 !== $data['status']) { + return 'Error getting comments: '.($data['message'] ?? 'Unknown error'); + } + + return array_map(fn ($comment) => [ + 'id' => $comment['id'], + 'file_key' => $comment['file_key'], + 'parent_id' => $comment['parent_id'], + 'user' => [ + 'id' => $comment['user']['id'], + 'handle' => $comment['user']['handle'], + 'img_url' => $comment['user']['img_url'], + ], + 'created_at' => $comment['created_at'], + 'resolved_at' => $comment['resolved_at'], + 'message' => $comment['message'], + 'client_meta' => [ + 'x' => $comment['client_meta']['x'], + 'y' => $comment['client_meta']['y'], + 'node_id' => $comment['client_meta']['node_id'], + 'node_offset' => $comment['client_meta']['node_offset'], + ], + 'order_id' => $comment['order_id'], + ], $data['comments'] ?? []); + } catch (\Exception $e) { + return 'Error getting comments: '.$e->getMessage(); + } + } + + /** + * Get Figma team projects. + * + * @param string $teamId Team ID + * + * @return array|string + */ + public function getTeamProjects(string $teamId): array|string + { + try { + $response = $this->httpClient->request('GET', "https://api.figma.com/{$this->apiVersion}/teams/{$teamId}/projects", [ + 'headers' => [ + 'X-Figma-Token' => $this->accessToken, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && 200 !== $data['status']) { + return 'Error getting team projects: '.($data['message'] ?? 'Unknown error'); + } + + return array_map(fn ($project) => [ + 'id' => $project['id'], + 'name' => $project['name'], + ], $data['projects'] ?? []); + } catch (\Exception $e) { + return 'Error getting team projects: '.$e->getMessage(); + } + } + + /** + * Get Figma team files. + * + * @param string $teamId Team ID + * @param string $projectId Project ID (optional) + * @param string $branchData Include branch data + * + * @return array, + * }>|string + */ + public function getTeamFiles( + string $teamId, + string $projectId = '', + string $branchData = '', + ): array|string { + try { + $params = []; + + if ($projectId) { + $params['project_ids'] = $projectId; + } + if ($branchData) { + $params['branch_data'] = $branchData; + } + + $response = $this->httpClient->request('GET', "https://api.figma.com/{$this->apiVersion}/teams/{$teamId}/files", [ + 'headers' => [ + 'X-Figma-Token' => $this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && 200 !== $data['status']) { + return 'Error getting team files: '.($data['message'] ?? 'Unknown error'); + } + + return array_map(fn ($file) => [ + 'key' => $file['key'], + 'name' => $file['name'], + 'last_modified' => $file['last_modified'], + 'thumbnail_url' => $file['thumbnail_url'], + 'link_access' => $file['link_access'], + 'project' => $file['project'] ? [ + 'id' => $file['project']['id'], + 'name' => $file['project']['name'], + ] : null, + 'branches' => array_map(fn ($branch) => [ + 'key' => $branch['key'], + 'name' => $branch['name'], + 'thumbnail_url' => $branch['thumbnail_url'], + 'last_modified' => $branch['last_modified'], + ], $file['branches'] ?? []), + ], $data['files'] ?? []); + } catch (\Exception $e) { + return 'Error getting team files: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/FileManagement.php b/src/agent/src/Toolbox/Tool/FileManagement.php new file mode 100644 index 000000000..b2477eb72 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/FileManagement.php @@ -0,0 +1,206 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; + +/** + * @author Mathieu Ledru + */ +#[AsTool('read_file', 'Read contents of a file from disk')] +#[AsTool('write_file', 'Write content to a file on disk', method: 'writeFile')] +#[AsTool('copy_file', 'Copy a file from one location to another', method: 'copyFile')] +#[AsTool('move_file', 'Move or rename a file from one location to another', method: 'moveFile')] +#[AsTool('delete_file', 'Delete a file from disk', method: 'deleteFile')] +#[AsTool('list_directory', 'List files and directories in a specified folder', method: 'listDirectory')] +final readonly class FileManagement +{ + public function __construct( + private Filesystem $filesystem = new Filesystem(), + private string $basePath = '', + ) { + } + + /** + * @param string $filePath The path of the file to read + */ + public function __invoke(string $filePath): string + { + try { + $fullPath = $this->getFullPath($filePath); + + if (!file_exists($fullPath)) { + return "Error: no such file or directory: {$filePath}"; + } + + if (!is_readable($fullPath)) { + return "Error: file is not readable: {$filePath}"; + } + + $content = file_get_contents($fullPath); + + if (false === $content) { + return "Error: unable to read file: {$filePath}"; + } + + return $content; + } catch (\Exception $e) { + return 'Error: '.$e->getMessage(); + } + } + + /** + * @param string $filePath The path of the file to write + * @param string $content The content to write to the file + * @param bool $append Whether to append to an existing file + */ + public function writeFile(string $filePath, string $content, bool $append = false): string + { + try { + $fullPath = $this->getFullPath($filePath); + + // Create directory if it doesn't exist + $directory = \dirname($fullPath); + if (!is_dir($directory)) { + $this->filesystem->mkdir($directory); + } + + if ($append) { + file_put_contents($fullPath, $content, \FILE_APPEND | \LOCK_EX); + } else { + file_put_contents($fullPath, $content, \LOCK_EX); + } + + return "File written successfully to {$filePath}."; + } catch (\Exception $e) { + return 'Error: '.$e->getMessage(); + } + } + + /** + * @param string $sourcePath The path of the file to copy + * @param string $destinationPath The path to save the copied file + */ + public function copyFile(string $sourcePath, string $destinationPath): string + { + try { + $fullSourcePath = $this->getFullPath($sourcePath); + $fullDestinationPath = $this->getFullPath($destinationPath); + + if (!file_exists($fullSourcePath)) { + return "Error: no such file or directory: {$sourcePath}"; + } + + // Create destination directory if it doesn't exist + $destinationDirectory = \dirname($fullDestinationPath); + if (!is_dir($destinationDirectory)) { + $this->filesystem->mkdir($destinationDirectory); + } + + $this->filesystem->copy($fullSourcePath, $fullDestinationPath); + + return "File copied successfully from {$sourcePath} to {$destinationPath}."; + } catch (\Exception $e) { + return 'Error: '.$e->getMessage(); + } + } + + /** + * @param string $sourcePath The path of the file to move + * @param string $destinationPath The new path for the moved file + */ + public function moveFile(string $sourcePath, string $destinationPath): string + { + try { + $fullSourcePath = $this->getFullPath($sourcePath); + $fullDestinationPath = $this->getFullPath($destinationPath); + + if (!file_exists($fullSourcePath)) { + return "Error: no such file or directory: {$sourcePath}"; + } + + // Create destination directory if it doesn't exist + $destinationDirectory = \dirname($fullDestinationPath); + if (!is_dir($destinationDirectory)) { + $this->filesystem->mkdir($destinationDirectory); + } + + $this->filesystem->rename($fullSourcePath, $fullDestinationPath); + + return "File moved successfully from {$sourcePath} to {$destinationPath}."; + } catch (\Exception $e) { + return 'Error: '.$e->getMessage(); + } + } + + /** + * @param string $filePath The path of the file to delete + */ + public function deleteFile(string $filePath): string + { + try { + $fullPath = $this->getFullPath($filePath); + + if (!file_exists($fullPath)) { + return "Error: no such file or directory: {$filePath}"; + } + + $this->filesystem->remove($fullPath); + + return "File deleted successfully: {$filePath}."; + } catch (\Exception $e) { + return 'Error: '.$e->getMessage(); + } + } + + /** + * @param string $dirPath The directory path to list (defaults to current directory) + */ + public function listDirectory(string $dirPath = '.'): string + { + try { + $fullPath = $this->getFullPath($dirPath); + + if (!is_dir($fullPath)) { + return "Error: no such directory: {$dirPath}"; + } + + $entries = scandir($fullPath); + + if (false === $entries) { + return "Error: unable to read directory: {$dirPath}"; + } + + // Filter out . and .. entries + $entries = array_filter($entries, fn (string $entry) => '.' !== $entry && '..' !== $entry); + + if (empty($entries)) { + return "No files found in directory {$dirPath}"; + } + + return implode("\n", $entries); + } catch (\Exception $e) { + return 'Error: '.$e->getMessage(); + } + } + + private function getFullPath(string $path): string + { + if (empty($this->basePath)) { + return Path::canonicalize($path); + } + + return Path::canonicalize($this->basePath.'/'.$path); + } +} diff --git a/src/agent/src/Toolbox/Tool/FinancialDatasets.php b/src/agent/src/Toolbox/Tool/FinancialDatasets.php new file mode 100644 index 000000000..d62a5d947 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/FinancialDatasets.php @@ -0,0 +1,894 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('financial_datasets_search', 'Tool that searches financial datasets')] +#[AsTool('financial_datasets_get_company_data', 'Tool that gets company financial data', method: 'getCompanyData')] +#[AsTool('financial_datasets_get_stock_prices', 'Tool that gets stock price data', method: 'getStockPrices')] +#[AsTool('financial_datasets_get_earnings', 'Tool that gets earnings data', method: 'getEarnings')] +#[AsTool('financial_datasets_get_dividends', 'Tool that gets dividend data', method: 'getDividends')] +#[AsTool('financial_datasets_get_splits', 'Tool that gets stock split data', method: 'getSplits')] +#[AsTool('financial_datasets_get_insider_trading', 'Tool that gets insider trading data', method: 'getInsiderTrading')] +#[AsTool('financial_datasets_get_fundamentals', 'Tool that gets fundamental data', method: 'getFundamentals')] +final readonly class FinancialDatasets +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey = '', + private string $baseUrl = 'https://financialmodelingprep.com/api/v3', + private array $options = [], + ) { + } + + /** + * Search financial datasets. + * + * @param string $query Search query + * @param string $type Dataset type (stock, etf, mutual-fund, index, forex, crypto) + * @param string $exchange Exchange (NASDAQ, NYSE, AMEX, etc.) + * @param int $limit Number of results + * + * @return array{ + * success: bool, + * results: array, + * count: int, + * error: string, + * } + */ + public function __invoke( + string $query, + string $type = 'stock', + string $exchange = '', + int $limit = 50, + ): array { + try { + $params = [ + 'query' => $query, + 'limit' => max(1, min($limit, 1000)), + ]; + + if ($this->apiKey) { + $params['apikey'] = $this->apiKey; + } + + $endpoint = match ($type) { + 'stock' => 'stock-screener', + 'etf' => 'etf-screener', + 'mutual-fund' => 'mutual-fund-screener', + 'index' => 'index-screener', + 'forex' => 'forex-screener', + 'crypto' => 'crypto-screener', + default => 'stock-screener', + }; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/{$endpoint}", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'results' => array_map(fn ($result) => [ + 'symbol' => $result['symbol'] ?? '', + 'name' => $result['name'] ?? '', + 'price' => $result['price'] ?? 0.0, + 'exchange' => $result['exchange'] ?? '', + 'exchangeShortName' => $result['exchangeShortName'] ?? '', + 'type' => $result['type'] ?? $type, + 'country' => $result['country'] ?? '', + 'currency' => $result['currency'] ?? '', + 'isEtf' => $result['isEtf'] ?? false, + 'isActivelyTrading' => $result['isActivelyTrading'] ?? false, + 'marketCap' => $result['marketCap'] ?? 0.0, + 'volume' => $result['volume'] ?? 0, + 'change' => $result['change'] ?? 0.0, + 'changePercent' => $result['changePercent'] ?? 0.0, + ], $data), + 'count' => \count($data), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'results' => [], + 'count' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get company financial data. + * + * @param string $symbol Stock symbol + * @param string $period Period (annual, quarter) + * @param int $limit Number of periods + * + * @return array{ + * success: bool, + * company: array{ + * symbol: string, + * companyName: string, + * currency: string, + * industry: string, + * website: string, + * description: string, + * ceo: string, + * sector: string, + * country: string, + * fullTimeEmployees: string, + * phone: string, + * address: string, + * city: string, + * state: string, + * zip: string, + * dcfDiff: float, + * dcf: float, + * image: string, + * ipoDate: string, + * defaultImage: bool, + * isEtf: bool, + * isActivelyTrading: bool, + * isADR: bool, + * isFund: bool, + * isReit: bool, + * }, + * financials: array, + * error: string, + * } + */ + public function getCompanyData( + string $symbol, + string $period = 'annual', + int $limit = 5, + ): array { + try { + $params = [ + 'limit' => max(1, min($limit, 10)), + ]; + + if ($this->apiKey) { + $params['apikey'] = $this->apiKey; + } + + // Get company profile + $profileResponse = $this->httpClient->request('GET', "{$this->baseUrl}/profile/{$symbol}", [ + 'query' => array_merge($this->options, $params), + ]); + + $profileData = $profileResponse->toArray(); + $company = $profileData[0] ?? []; + + // Get income statement + $financialsResponse = $this->httpClient->request('GET', "{$this->baseUrl}/income-statement/{$symbol}", [ + 'query' => array_merge($this->options, $params), + ]); + + $financialsData = $financialsResponse->toArray(); + + return [ + 'success' => true, + 'company' => [ + 'symbol' => $company['symbol'] ?? $symbol, + 'companyName' => $company['companyName'] ?? '', + 'currency' => $company['currency'] ?? '', + 'industry' => $company['industry'] ?? '', + 'website' => $company['website'] ?? '', + 'description' => $company['description'] ?? '', + 'ceo' => $company['ceo'] ?? '', + 'sector' => $company['sector'] ?? '', + 'country' => $company['country'] ?? '', + 'fullTimeEmployees' => $company['fullTimeEmployees'] ?? '', + 'phone' => $company['phone'] ?? '', + 'address' => $company['address'] ?? '', + 'city' => $company['city'] ?? '', + 'state' => $company['state'] ?? '', + 'zip' => $company['zip'] ?? '', + 'dcfDiff' => $company['dcfDiff'] ?? 0.0, + 'dcf' => $company['dcf'] ?? 0.0, + 'image' => $company['image'] ?? '', + 'ipoDate' => $company['ipoDate'] ?? '', + 'defaultImage' => $company['defaultImage'] ?? false, + 'isEtf' => $company['isEtf'] ?? false, + 'isActivelyTrading' => $company['isActivelyTrading'] ?? false, + 'isADR' => $company['isADR'] ?? false, + 'isFund' => $company['isFund'] ?? false, + 'isReit' => $company['isReit'] ?? false, + ], + 'financials' => array_map(fn ($financial) => [ + 'date' => $financial['date'] ?? '', + 'symbol' => $financial['symbol'] ?? $symbol, + 'reportedCurrency' => $financial['reportedCurrency'] ?? '', + 'cik' => $financial['cik'] ?? '', + 'fillingDate' => $financial['fillingDate'] ?? '', + 'acceptedDate' => $financial['acceptedDate'] ?? '', + 'calendarYear' => $financial['calendarYear'] ?? '', + 'period' => $financial['period'] ?? '', + 'revenue' => $financial['revenue'] ?? 0.0, + 'costOfRevenue' => $financial['costOfRevenue'] ?? 0.0, + 'grossProfit' => $financial['grossProfit'] ?? 0.0, + 'grossProfitRatio' => $financial['grossProfitRatio'] ?? 0.0, + 'researchAndDevelopmentExpenses' => $financial['researchAndDevelopmentExpenses'] ?? 0.0, + 'generalAndAdministrativeExpenses' => $financial['generalAndAdministrativeExpenses'] ?? 0.0, + 'sellingAndMarketingExpenses' => $financial['sellingAndMarketingExpenses'] ?? 0.0, + 'sellingGeneralAndAdministrativeExpenses' => $financial['sellingGeneralAndAdministrativeExpenses'] ?? 0.0, + 'otherExpenses' => $financial['otherExpenses'] ?? 0.0, + 'operatingExpenses' => $financial['operatingExpenses'] ?? 0.0, + 'costAndExpenses' => $financial['costAndExpenses'] ?? 0.0, + 'interestIncome' => $financial['interestIncome'] ?? 0.0, + 'interestExpense' => $financial['interestExpense'] ?? 0.0, + 'depreciationAndAmortization' => $financial['depreciationAndAmortization'] ?? 0.0, + 'ebitda' => $financial['ebitda'] ?? 0.0, + 'ebitdaratio' => $financial['ebitdaratio'] ?? 0.0, + 'operatingIncome' => $financial['operatingIncome'] ?? 0.0, + 'operatingIncomeRatio' => $financial['operatingIncomeRatio'] ?? 0.0, + 'totalOtherIncomeExpensesNet' => $financial['totalOtherIncomeExpensesNet'] ?? 0.0, + 'incomeBeforeTax' => $financial['incomeBeforeTax'] ?? 0.0, + 'incomeBeforeTaxRatio' => $financial['incomeBeforeTaxRatio'] ?? 0.0, + 'incomeTaxExpense' => $financial['incomeTaxExpense'] ?? 0.0, + 'netIncome' => $financial['netIncome'] ?? 0.0, + 'netIncomeRatio' => $financial['netIncomeRatio'] ?? 0.0, + 'eps' => $financial['eps'] ?? 0.0, + 'epsdiluted' => $financial['epsdiluted'] ?? 0.0, + 'weightedAverageShsOut' => $financial['weightedAverageShsOut'] ?? 0.0, + 'weightedAverageShsOutDil' => $financial['weightedAverageShsOutDil'] ?? 0.0, + 'link' => $financial['link'] ?? '', + 'finalLink' => $financial['finalLink'] ?? '', + ], $financialsData), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'company' => [ + 'symbol' => $symbol, + 'companyName' => '', + 'currency' => '', + 'industry' => '', + 'website' => '', + 'description' => '', + 'ceo' => '', + 'sector' => '', + 'country' => '', + 'fullTimeEmployees' => '', + 'phone' => '', + 'address' => '', + 'city' => '', + 'state' => '', + 'zip' => '', + 'dcfDiff' => 0.0, + 'dcf' => 0.0, + 'image' => '', + 'ipoDate' => '', + 'defaultImage' => false, + 'isEtf' => false, + 'isActivelyTrading' => false, + 'isADR' => false, + 'isFund' => false, + 'isReit' => false, + ], + 'financials' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get stock price data. + * + * @param string $symbol Stock symbol + * @param string $from Start date (YYYY-MM-DD) + * @param string $to End date (YYYY-MM-DD) + * @param string $timeseries Time series (1day, 5day, 1month, 3month, 1year, 5year, max) + * + * @return array{ + * success: bool, + * prices: array, + * symbol: string, + * historical: bool, + * error: string, + * } + */ + public function getStockPrices( + string $symbol, + string $from = '', + string $to = '', + string $timeseries = '1month', + ): array { + try { + $params = []; + + if ($from && $to) { + $params['from'] = $from; + $params['to'] = $to; + } else { + $params['timeseries'] = $timeseries; + } + + if ($this->apiKey) { + $params['apikey'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/historical-price-full/{$symbol}", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + $historicalData = $data['historical'] ?? []; + + return [ + 'success' => true, + 'prices' => array_map(fn ($price) => [ + 'date' => $price['date'] ?? '', + 'open' => $price['open'] ?? 0.0, + 'high' => $price['high'] ?? 0.0, + 'low' => $price['low'] ?? 0.0, + 'close' => $price['close'] ?? 0.0, + 'adjClose' => $price['adjClose'] ?? 0.0, + 'volume' => $price['volume'] ?? 0, + 'unadjustedVolume' => $price['unadjustedVolume'] ?? 0.0, + 'change' => $price['change'] ?? 0.0, + 'changePercent' => $price['changePercent'] ?? 0.0, + 'vwap' => $price['vwap'] ?? 0.0, + 'label' => $price['label'] ?? '', + 'changeOverTime' => $price['changeOverTime'] ?? 0.0, + ], $historicalData), + 'symbol' => $data['symbol'] ?? $symbol, + 'historical' => $data['historical'] ?? true, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'prices' => [], + 'symbol' => $symbol, + 'historical' => false, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get earnings data. + * + * @param string $symbol Stock symbol + * @param int $limit Number of quarters + * + * @return array{ + * success: bool, + * earnings: array, + * error: string, + * } + */ + public function getEarnings( + string $symbol, + int $limit = 4, + ): array { + try { + $params = [ + 'limit' => max(1, min($limit, 20)), + ]; + + if ($this->apiKey) { + $params['apikey'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/earning_calendar/{$symbol}", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'earnings' => array_map(fn ($earning) => [ + 'date' => $earning['date'] ?? '', + 'symbol' => $earning['symbol'] ?? $symbol, + 'reportedDate' => $earning['reportedDate'] ?? '', + 'reportedEPS' => $earning['reportedEPS'] ?? 0.0, + 'estimatedEPS' => $earning['estimatedEPS'] ?? 0.0, + 'surprise' => $earning['surprise'] ?? 0.0, + 'surprisePercentage' => $earning['surprisePercentage'] ?? 0.0, + 'fiscalDateEnding' => $earning['fiscalDateEnding'] ?? '', + 'fiscalQuarter' => $earning['fiscalQuarter'] ?? 0, + 'fiscalYear' => $earning['fiscalYear'] ?? 0, + ], $data), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'earnings' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get dividend data. + * + * @param string $symbol Stock symbol + * @param int $limit Number of dividends + * + * @return array{ + * success: bool, + * dividends: array, + * error: string, + * } + */ + public function getDividends( + string $symbol, + int $limit = 10, + ): array { + try { + $params = [ + 'limit' => max(1, min($limit, 100)), + ]; + + if ($this->apiKey) { + $params['apikey'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/historical-price-full/stock_dividend/{$symbol}", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + $historicalData = $data['historical'] ?? []; + + return [ + 'success' => true, + 'dividends' => array_map(fn ($dividend) => [ + 'date' => $dividend['date'] ?? '', + 'label' => $dividend['label'] ?? '', + 'adjDividend' => $dividend['adjDividend'] ?? 0.0, + 'dividend' => $dividend['dividend'] ?? 0.0, + 'recordDate' => $dividend['recordDate'] ?? '', + 'paymentDate' => $dividend['paymentDate'] ?? '', + 'declarationDate' => $dividend['declarationDate'] ?? '', + ], $historicalData), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'dividends' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get stock split data. + * + * @param string $symbol Stock symbol + * @param int $limit Number of splits + * + * @return array{ + * success: bool, + * splits: array, + * error: string, + * } + */ + public function getSplits( + string $symbol, + int $limit = 10, + ): array { + try { + $params = [ + 'limit' => max(1, min($limit, 100)), + ]; + + if ($this->apiKey) { + $params['apikey'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/historical-price-full/stock_split/{$symbol}", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + $historicalData = $data['historical'] ?? []; + + return [ + 'success' => true, + 'splits' => array_map(fn ($split) => [ + 'date' => $split['date'] ?? '', + 'label' => $split['label'] ?? '', + 'numerator' => $split['numerator'] ?? 0.0, + 'denominator' => $split['denominator'] ?? 0.0, + 'splitRatio' => $split['splitRatio'] ?? '', + ], $historicalData), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'splits' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get insider trading data. + * + * @param string $symbol Stock symbol + * @param int $limit Number of transactions + * + * @return array{ + * success: bool, + * insiderTrading: array, + * error: string, + * } + */ + public function getInsiderTrading( + string $symbol, + int $limit = 10, + ): array { + try { + $params = [ + 'limit' => max(1, min($limit, 100)), + ]; + + if ($this->apiKey) { + $params['apikey'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/insider-trading", [ + 'query' => array_merge($this->options, array_merge($params, ['symbol' => $symbol])), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'insiderTrading' => array_map(fn ($transaction) => [ + 'symbol' => $transaction['symbol'] ?? $symbol, + 'name' => $transaction['name'] ?? '', + 'cik' => $transaction['cik'] ?? '', + 'reportingCik' => $transaction['reportingCik'] ?? '', + 'ownerType' => $transaction['ownerType'] ?? '', + 'isDirector' => $transaction['isDirector'] ?? false, + 'isOfficer' => $transaction['isOfficer'] ?? false, + 'isTenPercentOwner' => $transaction['isTenPercentOwner'] ?? false, + 'isOther' => $transaction['isOther'] ?? false, + 'otherText' => $transaction['otherText'] ?? '', + 'officerTitle' => $transaction['officerTitle'] ?? '', + 'dateOfTransaction' => $transaction['dateOfTransaction'] ?? '', + 'dateOfOriginalExecution' => $transaction['dateOfOriginalExecution'] ?? '', + 'numberOfSecuritiesTransacted' => $transaction['numberOfSecuritiesTransacted'] ?? 0.0, + 'numberOfSecuritiesOwned' => $transaction['numberOfSecuritiesOwned'] ?? 0.0, + 'sharesOwnedFollowingTransaction' => $transaction['sharesOwnedFollowingTransaction'] ?? 0.0, + 'ownedFollowingTransaction' => $transaction['ownedFollowingTransaction'] ?? 0.0, + 'transactionAcquiredDisposedCode' => $transaction['transactionAcquiredDisposedCode'] ?? '', + 'transactionAcquiredDisposedCodeDescription' => $transaction['transactionAcquiredDisposedCodeDescription'] ?? '', + 'transactionPricePerShare' => $transaction['transactionPricePerShare'] ?? 0.0, + 'transactionShares' => $transaction['transactionShares'] ?? 0.0, + 'transactionTotalValue' => $transaction['transactionTotalValue'] ?? 0.0, + 'transactionCode' => $transaction['transactionCode'] ?? '', + 'transactionCodeDescription' => $transaction['transactionCodeDescription'] ?? '', + 'transactionTimeliness' => $transaction['transactionTimeliness'] ?? '', + 'transactionTimelinessDescription' => $transaction['transactionTimelinessDescription'] ?? '', + 'filingDate' => $transaction['filingDate'] ?? '', + 'filingUrl' => $transaction['filingUrl'] ?? '', + 'amendmentDate' => $transaction['amendmentDate'] ?? '', + 'amendmentUrl' => $transaction['amendmentUrl'] ?? '', + 'reportOrFilingPeriodDateOfEvent' => $transaction['reportOrFilingPeriodDateOfEvent'] ?? '', + 'isNoSecuritiesInvolved' => $transaction['isNoSecuritiesInvolved'] ?? false, + 'securityTitle' => $transaction['securityTitle'] ?? '', + 'securityClass' => $transaction['securityClass'] ?? '', + 'securitiesAcquired' => $transaction['securitiesAcquired'] ?? 0.0, + 'securitiesDisposed' => $transaction['securitiesDisposed'] ?? 0.0, + 'securitiesOwned' => $transaction['securitiesOwned'] ?? 0.0, + 'securitiesOwnedFollowingTransaction' => $transaction['securitiesOwnedFollowingTransaction'] ?? 0.0, + 'natureOfOwnership' => $transaction['natureOfOwnership'] ?? '', + 'natureOfOwnershipDescription' => $transaction['natureOfOwnershipDescription'] ?? '', + 'securityAcquiredDisposed' => $transaction['securityAcquiredDisposed'] ?? '', + 'securityAcquiredDisposedDescription' => $transaction['securityAcquiredDisposedDescription'] ?? '', + 'transactionDate' => $transaction['transactionDate'] ?? '', + 'underlyingSecurityTitle' => $transaction['underlyingSecurityTitle'] ?? '', + 'underlyingSecurityShares' => $transaction['underlyingSecurityShares'] ?? 0.0, + 'underlyingSecurityValue' => $transaction['underlyingSecurityValue'] ?? 0.0, + 'ownershipNature' => $transaction['ownershipNature'] ?? '', + 'ownershipNatureDescription' => $transaction['ownershipNatureDescription'] ?? '', + ], $data), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'insiderTrading' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get fundamental data. + * + * @param string $symbol Stock symbol + * @param int $limit Number of periods + * + * @return array{ + * success: bool, + * fundamentals: array, + * error: string, + * } + */ + public function getFundamentals( + string $symbol, + int $limit = 5, + ): array { + try { + $params = [ + 'limit' => max(1, min($limit, 10)), + ]; + + if ($this->apiKey) { + $params['apikey'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/income-statement/{$symbol}", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'fundamentals' => array_map(fn ($fundamental) => [ + 'symbol' => $fundamental['symbol'] ?? $symbol, + 'date' => $fundamental['date'] ?? '', + 'reportedCurrency' => $fundamental['reportedCurrency'] ?? '', + 'cik' => $fundamental['cik'] ?? '', + 'fillingDate' => $fundamental['fillingDate'] ?? '', + 'acceptedDate' => $fundamental['acceptedDate'] ?? '', + 'calendarYear' => $fundamental['calendarYear'] ?? '', + 'period' => $fundamental['period'] ?? '', + 'revenue' => $fundamental['revenue'] ?? 0.0, + 'costOfRevenue' => $fundamental['costOfRevenue'] ?? 0.0, + 'grossProfit' => $fundamental['grossProfit'] ?? 0.0, + 'grossProfitRatio' => $fundamental['grossProfitRatio'] ?? 0.0, + 'researchAndDevelopmentExpenses' => $fundamental['researchAndDevelopmentExpenses'] ?? 0.0, + 'generalAndAdministrativeExpenses' => $fundamental['generalAndAdministrativeExpenses'] ?? 0.0, + 'sellingAndMarketingExpenses' => $fundamental['sellingAndMarketingExpenses'] ?? 0.0, + 'sellingGeneralAndAdministrativeExpenses' => $fundamental['sellingGeneralAndAdministrativeExpenses'] ?? 0.0, + 'otherExpenses' => $fundamental['otherExpenses'] ?? 0.0, + 'operatingExpenses' => $fundamental['operatingExpenses'] ?? 0.0, + 'costAndExpenses' => $fundamental['costAndExpenses'] ?? 0.0, + 'interestIncome' => $fundamental['interestIncome'] ?? 0.0, + 'interestExpense' => $fundamental['interestExpense'] ?? 0.0, + 'depreciationAndAmortization' => $fundamental['depreciationAndAmortization'] ?? 0.0, + 'ebitda' => $fundamental['ebitda'] ?? 0.0, + 'ebitdaratio' => $fundamental['ebitdaratio'] ?? 0.0, + 'operatingIncome' => $fundamental['operatingIncome'] ?? 0.0, + 'operatingIncomeRatio' => $fundamental['operatingIncomeRatio'] ?? 0.0, + 'totalOtherIncomeExpensesNet' => $fundamental['totalOtherIncomeExpensesNet'] ?? 0.0, + 'incomeBeforeTax' => $fundamental['incomeBeforeTax'] ?? 0.0, + 'incomeBeforeTaxRatio' => $fundamental['incomeBeforeTaxRatio'] ?? 0.0, + 'incomeTaxExpense' => $fundamental['incomeTaxExpense'] ?? 0.0, + 'netIncome' => $fundamental['netIncome'] ?? 0.0, + 'netIncomeRatio' => $fundamental['netIncomeRatio'] ?? 0.0, + 'eps' => $fundamental['eps'] ?? 0.0, + 'epsdiluted' => $fundamental['epsdiluted'] ?? 0.0, + 'weightedAverageShsOut' => $fundamental['weightedAverageShsOut'] ?? 0.0, + 'weightedAverageShsOutDil' => $fundamental['weightedAverageShsOutDil'] ?? 0.0, + 'link' => $fundamental['link'] ?? '', + 'finalLink' => $fundamental['finalLink'] ?? '', + ], $data), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'fundamentals' => [], + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/GitHub.php b/src/agent/src/Toolbox/Tool/GitHub.php new file mode 100644 index 000000000..3c8e151b8 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GitHub.php @@ -0,0 +1,586 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('github_issues', 'Get open issues from GitHub repository', method: 'getIssues')] +#[AsTool('github_pull_requests', 'Get open pull requests from GitHub repository', method: 'getPullRequests')] +#[AsTool('github_issue', 'Get specific issue details from GitHub repository', method: 'getIssue')] +#[AsTool('github_pull_request', 'Get specific pull request details from GitHub repository', method: 'getPullRequest')] +#[AsTool('github_branches', 'List all branches in GitHub repository', method: 'getBranches')] +#[AsTool('github_files', 'List files in GitHub repository', method: 'getFiles')] +#[AsTool('github_file_content', 'Get content of a specific file from GitHub repository', method: 'getFileContent')] +#[AsTool('github_search_issues', 'Search issues and pull requests in GitHub repository', method: 'searchIssues')] +#[AsTool('github_search_code', 'Search code in GitHub repository', method: 'searchCode')] +#[AsTool('github_releases', 'Get releases from GitHub repository', method: 'getReleases')] +final readonly class GitHub +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $token, + private string $repository, + private array $options = [], + ) { + } + + /** + * Get open issues from GitHub repository. + * + * @return array + */ + public function getIssues(): array + { + try { + $response = $this->httpClient->request('GET', "https://api.github.com/repos/{$this->repository}/issues", [ + 'headers' => $this->getHeaders(), + 'query' => [ + 'state' => 'open', + 'per_page' => 50, + ], + ]); + + $data = $response->toArray(); + $issues = []; + + foreach ($data as $issue) { + // Filter out pull requests (they appear in issues API) + if (!isset($issue['pull_request'])) { + $issues[] = [ + 'number' => $issue['number'], + 'title' => $issue['title'], + 'state' => $issue['state'], + 'user' => $issue['user']['login'] ?? '', + 'created_at' => $issue['created_at'], + 'updated_at' => $issue['updated_at'], + 'html_url' => $issue['html_url'], + ]; + } + } + + return $issues; + } catch (\Exception $e) { + return [ + [ + 'number' => 0, + 'title' => 'Error', + 'state' => 'error', + 'user' => '', + 'created_at' => '', + 'updated_at' => '', + 'html_url' => '', + ], + ]; + } + } + + /** + * Get open pull requests from GitHub repository. + * + * @return array + */ + public function getPullRequests(): array + { + try { + $response = $this->httpClient->request('GET', "https://api.github.com/repos/{$this->repository}/pulls", [ + 'headers' => $this->getHeaders(), + 'query' => [ + 'state' => 'open', + 'per_page' => 50, + ], + ]); + + $data = $response->toArray(); + $pullRequests = []; + + foreach ($data as $pr) { + $pullRequests[] = [ + 'number' => $pr['number'], + 'title' => $pr['title'], + 'state' => $pr['state'], + 'user' => $pr['user']['login'] ?? '', + 'created_at' => $pr['created_at'], + 'updated_at' => $pr['updated_at'], + 'html_url' => $pr['html_url'], + 'base' => ['branch' => $pr['base']['ref']], + 'head' => ['branch' => $pr['head']['ref']], + ]; + } + + return $pullRequests; + } catch (\Exception $e) { + return [ + [ + 'number' => 0, + 'title' => 'Error', + 'state' => 'error', + 'user' => '', + 'created_at' => '', + 'updated_at' => '', + 'html_url' => '', + 'base' => ['branch' => ''], + 'head' => ['branch' => ''], + ], + ]; + } + } + + /** + * Get specific issue details from GitHub repository. + * + * @param int $issueNumber The issue number + * + * @return array{ + * number: int, + * title: string, + * body: string, + * state: string, + * user: string, + * created_at: string, + * updated_at: string, + * html_url: string, + * comments: array, + * } + */ + public function getIssue(int $issueNumber): array + { + try { + // Get issue details + $response = $this->httpClient->request('GET', "https://api.github.com/repos/{$this->repository}/issues/{$issueNumber}", [ + 'headers' => $this->getHeaders(), + ]); + + $issue = $response->toArray(); + + // Get comments + $commentsResponse = $this->httpClient->request('GET', "https://api.github.com/repos/{$this->repository}/issues/{$issueNumber}/comments", [ + 'headers' => $this->getHeaders(), + 'query' => ['per_page' => 10], + ]); + + $commentsData = $commentsResponse->toArray(); + $comments = []; + + foreach ($commentsData as $comment) { + $comments[] = [ + 'body' => $comment['body'], + 'user' => $comment['user']['login'] ?? '', + 'created_at' => $comment['created_at'], + ]; + } + + return [ + 'number' => $issue['number'], + 'title' => $issue['title'], + 'body' => $issue['body'] ?? '', + 'state' => $issue['state'], + 'user' => $issue['user']['login'] ?? '', + 'created_at' => $issue['created_at'], + 'updated_at' => $issue['updated_at'], + 'html_url' => $issue['html_url'], + 'comments' => $comments, + ]; + } catch (\Exception $e) { + return [ + 'number' => $issueNumber, + 'title' => 'Error', + 'body' => 'Unable to fetch issue: '.$e->getMessage(), + 'state' => 'error', + 'user' => '', + 'created_at' => '', + 'updated_at' => '', + 'html_url' => '', + 'comments' => [], + ]; + } + } + + /** + * Get specific pull request details from GitHub repository. + * + * @param int $prNumber The pull request number + * + * @return array{ + * number: int, + * title: string, + * body: string, + * state: string, + * user: string, + * created_at: string, + * updated_at: string, + * html_url: string, + * base: array{branch: string}, + * head: array{branch: string}, + * comments: array, + * } + */ + public function getPullRequest(int $prNumber): array + { + try { + // Get PR details + $response = $this->httpClient->request('GET', "https://api.github.com/repos/{$this->repository}/pulls/{$prNumber}", [ + 'headers' => $this->getHeaders(), + ]); + + $pr = $response->toArray(); + + // Get comments + $commentsResponse = $this->httpClient->request('GET', "https://api.github.com/repos/{$this->repository}/issues/{$prNumber}/comments", [ + 'headers' => $this->getHeaders(), + 'query' => ['per_page' => 10], + ]); + + $commentsData = $commentsResponse->toArray(); + $comments = []; + + foreach ($commentsData as $comment) { + $comments[] = [ + 'body' => $comment['body'], + 'user' => $comment['user']['login'] ?? '', + 'created_at' => $comment['created_at'], + ]; + } + + return [ + 'number' => $pr['number'], + 'title' => $pr['title'], + 'body' => $pr['body'] ?? '', + 'state' => $pr['state'], + 'user' => $pr['user']['login'] ?? '', + 'created_at' => $pr['created_at'], + 'updated_at' => $pr['updated_at'], + 'html_url' => $pr['html_url'], + 'base' => ['branch' => $pr['base']['ref']], + 'head' => ['branch' => $pr['head']['ref']], + 'comments' => $comments, + ]; + } catch (\Exception $e) { + return [ + 'number' => $prNumber, + 'title' => 'Error', + 'body' => 'Unable to fetch pull request: '.$e->getMessage(), + 'state' => 'error', + 'user' => '', + 'created_at' => '', + 'updated_at' => '', + 'html_url' => '', + 'base' => ['branch' => ''], + 'head' => ['branch' => ''], + 'comments' => [], + ]; + } + } + + /** + * List all branches in GitHub repository. + * + * @return array + */ + public function getBranches(): array + { + try { + $response = $this->httpClient->request('GET', "https://api.github.com/repos/{$this->repository}/branches", [ + 'headers' => $this->getHeaders(), + 'query' => ['per_page' => 100], + ]); + + $data = $response->toArray(); + $branches = []; + + foreach ($data as $branch) { + $branches[] = [ + 'name' => $branch['name'], + 'protected' => $branch['protected'] ?? false, + 'commit' => [ + 'sha' => $branch['commit']['sha'], + 'url' => $branch['commit']['url'], + ], + ]; + } + + return $branches; + } catch (\Exception $e) { + return [ + [ + 'name' => 'error', + 'protected' => false, + 'commit' => ['sha' => '', 'url' => ''], + ], + ]; + } + } + + /** + * List files in GitHub repository. + * + * @param string $branch The branch to list files from (defaults to main) + * @param string $path The path to list (defaults to root) + * + * @return array + */ + public function getFiles(string $branch = 'main', string $path = ''): array + { + try { + $response = $this->httpClient->request('GET', "https://api.github.com/repos/{$this->repository}/contents/{$path}", [ + 'headers' => $this->getHeaders(), + 'query' => ['ref' => $branch], + ]); + + $data = $response->toArray(); + $files = []; + + foreach ($data as $file) { + $files[] = [ + 'name' => $file['name'], + 'path' => $file['path'], + 'type' => $file['type'], + 'size' => $file['size'] ?? 0, + 'download_url' => $file['download_url'] ?? null, + ]; + } + + return $files; + } catch (\Exception $e) { + return [ + [ + 'name' => 'error', + 'path' => '', + 'type' => 'error', + 'size' => 0, + 'download_url' => null, + ], + ]; + } + } + + /** + * Get content of a specific file from GitHub repository. + * + * @param string $filePath The path to the file + * @param string $branch The branch to get the file from (defaults to main) + */ + public function getFileContent(string $filePath, string $branch = 'main'): string + { + try { + $response = $this->httpClient->request('GET', "https://api.github.com/repos/{$this->repository}/contents/{$filePath}", [ + 'headers' => $this->getHeaders(), + 'query' => ['ref' => $branch], + ]); + + $data = $response->toArray(); + + if (isset($data['content'])) { + return base64_decode($data['content']); + } + + return 'File not found or not accessible'; + } catch (\Exception $e) { + return 'Error fetching file content: '.$e->getMessage(); + } + } + + /** + * Search issues and pull requests in GitHub repository. + * + * @param string $query The search query + * + * @return array + */ + public function searchIssues(string $query): array + { + try { + $searchQuery = "repo:{$this->repository} {$query}"; + $response = $this->httpClient->request('GET', 'https://api.github.com/search/issues', [ + 'headers' => $this->getHeaders(), + 'query' => [ + 'q' => $searchQuery, + 'per_page' => 10, + ], + ]); + + $data = $response->toArray(); + $results = []; + + foreach ($data['items'] as $item) { + $results[] = [ + 'number' => $item['number'], + 'title' => $item['title'], + 'state' => $item['state'], + 'html_url' => $item['html_url'], + ]; + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'number' => 0, + 'title' => 'Error', + 'state' => 'error', + 'html_url' => '', + ], + ]; + } + } + + /** + * Search code in GitHub repository. + * + * @param string $query The search query + * + * @return array + */ + public function searchCode(string $query): array + { + try { + $searchQuery = "{$query} repo:{$this->repository}"; + $response = $this->httpClient->request('GET', 'https://api.github.com/search/code', [ + 'headers' => $this->getHeaders(), + 'query' => [ + 'q' => $searchQuery, + 'per_page' => 10, + ], + ]); + + $data = $response->toArray(); + $results = []; + + foreach ($data['items'] as $item) { + $results[] = [ + 'name' => $item['name'], + 'path' => $item['path'], + 'html_url' => $item['html_url'], + 'repository' => ['full_name' => $item['repository']['full_name']], + ]; + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'name' => 'error', + 'path' => '', + 'html_url' => '', + 'repository' => ['full_name' => ''], + ], + ]; + } + } + + /** + * Get releases from GitHub repository. + * + * @return array + */ + public function getReleases(): array + { + try { + $response = $this->httpClient->request('GET', "https://api.github.com/repos/{$this->repository}/releases", [ + 'headers' => $this->getHeaders(), + 'query' => ['per_page' => 10], + ]); + + $data = $response->toArray(); + $releases = []; + + foreach ($data as $release) { + $releases[] = [ + 'tag_name' => $release['tag_name'], + 'name' => $release['name'] ?? '', + 'body' => $release['body'] ?? '', + 'created_at' => $release['created_at'], + 'published_at' => $release['published_at'] ?? '', + 'html_url' => $release['html_url'], + ]; + } + + return $releases; + } catch (\Exception $e) { + return [ + [ + 'tag_name' => 'error', + 'name' => 'Error', + 'body' => 'Unable to fetch releases: '.$e->getMessage(), + 'created_at' => '', + 'published_at' => '', + 'html_url' => '', + ], + ]; + } + } + + /** + * Get authentication headers. + * + * @return array + */ + private function getHeaders(): array + { + return [ + 'Authorization' => "token {$this->token}", + 'Accept' => 'application/vnd.github.v3+json', + 'User-Agent' => 'Symfony-AI-Agent/1.0', + ]; + } +} diff --git a/src/agent/src/Toolbox/Tool/GitHubActions.php b/src/agent/src/Toolbox/Tool/GitHubActions.php new file mode 100644 index 000000000..c20ec6cef --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GitHubActions.php @@ -0,0 +1,573 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('github_actions_get_workflows', 'Tool that gets GitHub Actions workflows')] +#[AsTool('github_actions_get_workflow_runs', 'Tool that gets GitHub Actions workflow runs', method: 'getWorkflowRuns')] +#[AsTool('github_actions_get_jobs', 'Tool that gets GitHub Actions jobs', method: 'getJobs')] +#[AsTool('github_actions_get_logs', 'Tool that gets GitHub Actions logs', method: 'getLogs')] +#[AsTool('github_actions_rerun_workflow', 'Tool that reruns GitHub Actions workflows', method: 'rerunWorkflow')] +#[AsTool('github_actions_cancel_workflow', 'Tool that cancels GitHub Actions workflows', method: 'cancelWorkflow')] +final readonly class GitHubActions +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private string $apiVersion = 'v3', + private array $options = [], + ) { + } + + /** + * Get GitHub Actions workflows. + * + * @param string $owner Repository owner + * @param string $repo Repository name + * @param int $perPage Number of workflows per page + * @param int $page Page number + * + * @return array + */ + public function __invoke( + string $owner, + string $repo, + int $perPage = 30, + int $page = 1, + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + ]; + + $response = $this->httpClient->request('GET', "https://api.github.com/repos/{$owner}/{$repo}/actions/workflows", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Accept' => 'application/vnd.github.'.$this->apiVersion.'+json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['message'])) { + return []; + } + + return array_map(fn ($workflow) => [ + 'id' => $workflow['id'], + 'node_id' => $workflow['node_id'], + 'name' => $workflow['name'], + 'path' => $workflow['path'], + 'state' => $workflow['state'], + 'created_at' => $workflow['created_at'], + 'updated_at' => $workflow['updated_at'], + 'url' => $workflow['url'], + 'html_url' => $workflow['html_url'], + 'badge_url' => $workflow['badge_url'], + ], $data['workflows'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get GitHub Actions workflow runs. + * + * @param string $owner Repository owner + * @param string $repo Repository name + * @param int $workflowId Workflow ID (optional) + * @param string $actor Actor filter (optional) + * @param string $branch Branch filter (optional) + * @param string $event Event filter (optional) + * @param string $status Status filter (completed, action_required, cancelled, failure, neutral, skipped, stale, success, timed_out, in_progress, queued, requested, waiting) + * @param string $conclusion Conclusion filter (success, failure, neutral, cancelled, skipped, timed_out, action_required) + * @param int $perPage Number of runs per page + * @param int $page Page number + * + * @return array, + * created_at: string, + * updated_at: string, + * jobs_url: string, + * logs_url: string, + * check_suite_url: string, + * artifacts_url: string, + * cancel_url: string, + * rerun_url: string, + * workflow_url: string, + * head_commit: array{ + * id: string, + * tree_id: string, + * message: string, + * timestamp: string, + * author: array{name: string, email: string}, + * committer: array{name: string, email: string}, + * }, + * repository: array{ + * id: int, + * node_id: string, + * name: string, + * full_name: string, + * private: bool, + * owner: array{login: string, id: int, node_id: string, avatar_url: string}, + * html_url: string, + * description: string, + * fork: bool, + * url: string, + * created_at: string, + * updated_at: string, + * pushed_at: string, + * clone_url: string, + * default_branch: string, + * }, + * head_repository: array{ + * id: int, + * node_id: string, + * name: string, + * full_name: string, + * private: bool, + * owner: array{login: string, id: int, node_id: string, avatar_url: string}, + * html_url: string, + * description: string, + * fork: bool, + * url: string, + * created_at: string, + * updated_at: string, + * pushed_at: string, + * clone_url: string, + * default_branch: string, + * }, + * }> + */ + public function getWorkflowRuns( + string $owner, + string $repo, + int $workflowId = 0, + string $actor = '', + string $branch = '', + string $event = '', + string $status = '', + string $conclusion = '', + int $perPage = 30, + int $page = 1, + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + ]; + + if ($actor) { + $params['actor'] = $actor; + } + if ($branch) { + $params['branch'] = $branch; + } + if ($event) { + $params['event'] = $event; + } + if ($status) { + $params['status'] = $status; + } + if ($conclusion) { + $params['conclusion'] = $conclusion; + } + + $url = $workflowId > 0 + ? "https://api.github.com/repos/{$owner}/{$repo}/actions/workflows/{$workflowId}/runs" + : "https://api.github.com/repos/{$owner}/{$repo}/actions/runs"; + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Accept' => 'application/vnd.github.'.$this->apiVersion.'+json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['message'])) { + return []; + } + + return array_map(fn ($run) => [ + 'id' => $run['id'], + 'name' => $run['name'], + 'head_branch' => $run['head_branch'], + 'head_sha' => $run['head_sha'], + 'run_number' => $run['run_number'], + 'event' => $run['event'], + 'status' => $run['status'], + 'conclusion' => $run['conclusion'], + 'workflow_id' => $run['workflow_id'], + 'check_suite_id' => $run['check_suite_id'], + 'check_suite_node_id' => $run['check_suite_node_id'], + 'url' => $run['url'], + 'html_url' => $run['html_url'], + 'pull_requests' => array_map(fn ($pr) => [ + 'url' => $pr['url'], + 'id' => $pr['id'], + 'number' => $pr['number'], + 'head' => [ + 'ref' => $pr['head']['ref'], + 'sha' => $pr['head']['sha'], + 'repo' => [ + 'id' => $pr['head']['repo']['id'], + 'url' => $pr['head']['repo']['url'], + 'name' => $pr['head']['repo']['name'], + ], + ], + 'base' => [ + 'ref' => $pr['base']['ref'], + 'sha' => $pr['base']['sha'], + 'repo' => [ + 'id' => $pr['base']['repo']['id'], + 'url' => $pr['base']['repo']['url'], + 'name' => $pr['base']['repo']['name'], + ], + ], + ], $run['pull_requests'] ?? []), + 'created_at' => $run['created_at'], + 'updated_at' => $run['updated_at'], + 'jobs_url' => $run['jobs_url'], + 'logs_url' => $run['logs_url'], + 'check_suite_url' => $run['check_suite_url'], + 'artifacts_url' => $run['artifacts_url'], + 'cancel_url' => $run['cancel_url'], + 'rerun_url' => $run['rerun_url'], + 'workflow_url' => $run['workflow_url'], + 'head_commit' => [ + 'id' => $run['head_commit']['id'], + 'tree_id' => $run['head_commit']['tree_id'], + 'message' => $run['head_commit']['message'], + 'timestamp' => $run['head_commit']['timestamp'], + 'author' => [ + 'name' => $run['head_commit']['author']['name'], + 'email' => $run['head_commit']['author']['email'], + ], + 'committer' => [ + 'name' => $run['head_commit']['committer']['name'], + 'email' => $run['head_commit']['committer']['email'], + ], + ], + 'repository' => [ + 'id' => $run['repository']['id'], + 'node_id' => $run['repository']['node_id'], + 'name' => $run['repository']['name'], + 'full_name' => $run['repository']['full_name'], + 'private' => $run['repository']['private'], + 'owner' => [ + 'login' => $run['repository']['owner']['login'], + 'id' => $run['repository']['owner']['id'], + 'node_id' => $run['repository']['owner']['node_id'], + 'avatar_url' => $run['repository']['owner']['avatar_url'], + ], + 'html_url' => $run['repository']['html_url'], + 'description' => $run['repository']['description'], + 'fork' => $run['repository']['fork'], + 'url' => $run['repository']['url'], + 'created_at' => $run['repository']['created_at'], + 'updated_at' => $run['repository']['updated_at'], + 'pushed_at' => $run['repository']['pushed_at'], + 'clone_url' => $run['repository']['clone_url'], + 'default_branch' => $run['repository']['default_branch'], + ], + 'head_repository' => [ + 'id' => $run['head_repository']['id'], + 'node_id' => $run['head_repository']['node_id'], + 'name' => $run['head_repository']['name'], + 'full_name' => $run['head_repository']['full_name'], + 'private' => $run['head_repository']['private'], + 'owner' => [ + 'login' => $run['head_repository']['owner']['login'], + 'id' => $run['head_repository']['owner']['id'], + 'node_id' => $run['head_repository']['owner']['node_id'], + 'avatar_url' => $run['head_repository']['owner']['avatar_url'], + ], + 'html_url' => $run['head_repository']['html_url'], + 'description' => $run['head_repository']['description'], + 'fork' => $run['head_repository']['fork'], + 'url' => $run['head_repository']['url'], + 'created_at' => $run['head_repository']['created_at'], + 'updated_at' => $run['head_repository']['updated_at'], + 'pushed_at' => $run['head_repository']['pushed_at'], + 'clone_url' => $run['head_repository']['clone_url'], + 'default_branch' => $run['head_repository']['default_branch'], + ], + ], $data['workflow_runs'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get GitHub Actions jobs. + * + * @param string $owner Repository owner + * @param string $repo Repository name + * @param int $runId Workflow run ID + * @param string $filter Job filter (latest, all) + * + * @return array, + * check_run_url: string, + * labels: array, + * runner_id: int|null, + * runner_name: string|null, + * runner_group_id: int|null, + * runner_group_name: string|null, + * }> + */ + public function getJobs( + string $owner, + string $repo, + int $runId, + string $filter = 'latest', + ): array { + try { + $params = [ + 'filter' => $filter, + ]; + + $response = $this->httpClient->request('GET', "https://api.github.com/repos/{$owner}/{$repo}/actions/runs/{$runId}/jobs", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Accept' => 'application/vnd.github.'.$this->apiVersion.'+json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['message'])) { + return []; + } + + return array_map(fn ($job) => [ + 'id' => $job['id'], + 'run_id' => $job['run_id'], + 'run_url' => $job['run_url'], + 'node_id' => $job['node_id'], + 'head_sha' => $job['head_sha'], + 'url' => $job['url'], + 'html_url' => $job['html_url'], + 'status' => $job['status'], + 'conclusion' => $job['conclusion'], + 'created_at' => $job['created_at'], + 'started_at' => $job['started_at'], + 'completed_at' => $job['completed_at'], + 'name' => $job['name'], + 'steps' => array_map(fn ($step) => [ + 'name' => $step['name'], + 'status' => $step['status'], + 'conclusion' => $step['conclusion'], + 'number' => $step['number'], + 'started_at' => $step['started_at'], + 'completed_at' => $step['completed_at'], + ], $job['steps'] ?? []), + 'check_run_url' => $job['check_run_url'], + 'labels' => $job['labels'] ?? [], + 'runner_id' => $job['runner_id'], + 'runner_name' => $job['runner_name'], + 'runner_group_id' => $job['runner_group_id'], + 'runner_group_name' => $job['runner_group_name'], + ], $data['jobs'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get GitHub Actions logs. + * + * @param string $owner Repository owner + * @param string $repo Repository name + * @param int $runId Workflow run ID + * + * @return array{ + * logs: string, + * truncated: bool, + * }|string + */ + public function getLogs( + string $owner, + string $repo, + int $runId, + ): array|string { + try { + $response = $this->httpClient->request('GET', "https://api.github.com/repos/{$owner}/{$repo}/actions/runs/{$runId}/logs", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Accept' => 'application/vnd.github.'.$this->apiVersion.'+json', + ], + ]); + + if (200 === $response->getStatusCode()) { + $logs = $response->getContent(); + + return [ + 'logs' => $logs, + 'truncated' => false, + ]; + } + + $data = $response->toArray(); + + return 'Error getting logs: '.($data['message'] ?? 'Unknown error'); + } catch (\Exception $e) { + return 'Error getting logs: '.$e->getMessage(); + } + } + + /** + * Rerun a GitHub Actions workflow. + * + * @param string $owner Repository owner + * @param string $repo Repository name + * @param int $runId Workflow run ID + * @param bool $enableDebug Enable debug logging + */ + public function rerunWorkflow( + string $owner, + string $repo, + int $runId, + bool $enableDebug = false, + ): string { + try { + $payload = []; + + if ($enableDebug) { + $payload['enable_debug_logging'] = true; + } + + $response = $this->httpClient->request('POST', "https://api.github.com/repos/{$owner}/{$repo}/actions/runs/{$runId}/rerun", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Accept' => 'application/vnd.github.'.$this->apiVersion.'+json', + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + if (201 === $response->getStatusCode()) { + return 'Workflow rerun successfully triggered'; + } + + $data = $response->toArray(); + + return 'Error rerunning workflow: '.($data['message'] ?? 'Unknown error'); + } catch (\Exception $e) { + return 'Error rerunning workflow: '.$e->getMessage(); + } + } + + /** + * Cancel a GitHub Actions workflow. + * + * @param string $owner Repository owner + * @param string $repo Repository name + * @param int $runId Workflow run ID + */ + public function cancelWorkflow( + string $owner, + string $repo, + int $runId, + ): string { + try { + $response = $this->httpClient->request('POST', "https://api.github.com/repos/{$owner}/{$repo}/actions/runs/{$runId}/cancel", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Accept' => 'application/vnd.github.'.$this->apiVersion.'+json', + ], + ]); + + if (202 === $response->getStatusCode()) { + return 'Workflow cancel request submitted successfully'; + } + + $data = $response->toArray(); + + return 'Error canceling workflow: '.($data['message'] ?? 'Unknown error'); + } catch (\Exception $e) { + return 'Error canceling workflow: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/GitLab.php b/src/agent/src/Toolbox/Tool/GitLab.php new file mode 100644 index 000000000..dd58e38fc --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GitLab.php @@ -0,0 +1,468 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('gitlab_get_projects', 'Tool that gets GitLab projects')] +#[AsTool('gitlab_create_project', 'Tool that creates GitLab projects', method: 'createProject')] +#[AsTool('gitlab_get_issues', 'Tool that gets GitLab issues', method: 'getIssues')] +#[AsTool('gitlab_create_issue', 'Tool that creates GitLab issues', method: 'createIssue')] +#[AsTool('gitlab_get_merge_requests', 'Tool that gets GitLab merge requests', method: 'getMergeRequests')] +#[AsTool('gitlab_get_repository_files', 'Tool that gets GitLab repository files', method: 'getRepositoryFiles')] +final readonly class GitLab +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + #[\SensitiveParameter] private string $gitlabUrl, + private string $apiVersion = 'v4', + private array $options = [], + ) { + } + + /** + * Get GitLab projects. + * + * @param int $limit Number of projects to retrieve + * @param string $search Search query + * @param string $orderBy Order by field (id, name, path, created_at, updated_at, last_activity_at) + * @param string $sort Sort order (asc, desc) + * + * @return array + */ + public function __invoke( + int $limit = 20, + string $search = '', + string $orderBy = 'last_activity_at', + string $sort = 'desc', + ): array { + try { + $params = [ + 'per_page' => min(max($limit, 1), 100), + 'order_by' => $orderBy, + 'sort' => $sort, + ]; + + if ($search) { + $params['search'] = $search; + } + + $response = $this->httpClient->request('GET', "{$this->gitlabUrl}/api/{$this->apiVersion}/projects", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + $projects = []; + foreach ($data as $project) { + $projects[] = [ + 'id' => $project['id'], + 'name' => $project['name'], + 'name_with_namespace' => $project['name_with_namespace'], + 'path' => $project['path'], + 'path_with_namespace' => $project['path_with_namespace'], + 'description' => $project['description'] ?? '', + 'visibility' => $project['visibility'], + 'created_at' => $project['created_at'], + 'updated_at' => $project['updated_at'], + 'last_activity_at' => $project['last_activity_at'], + 'web_url' => $project['web_url'], + 'ssh_url_to_repo' => $project['ssh_url_to_repo'], + 'http_url_to_repo' => $project['http_url_to_repo'], + 'default_branch' => $project['default_branch'], + 'star_count' => $project['star_count'] ?? 0, + 'forks_count' => $project['forks_count'] ?? 0, + 'open_issues_count' => $project['open_issues_count'] ?? 0, + ]; + } + + return $projects; + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a GitLab project. + * + * @param string $name Project name + * @param string $description Project description + * @param string $visibility Project visibility (private, internal, public) + * @param bool $initializeWithReadme Initialize with README + * + * @return array{ + * id: int, + * name: string, + * description: string, + * visibility: string, + * created_at: string, + * web_url: string, + * ssh_url_to_repo: string, + * http_url_to_repo: string, + * }|string + */ + public function createProject( + string $name, + string $description = '', + string $visibility = 'private', + bool $initializeWithReadme = true, + ): array|string { + try { + $payload = [ + 'name' => $name, + 'description' => $description, + 'visibility' => $visibility, + 'initialize_with_readme' => $initializeWithReadme, + ]; + + $response = $this->httpClient->request('POST', "{$this->gitlabUrl}/api/{$this->apiVersion}/projects", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['message'])) { + return 'Error creating project: '.$data['message']; + } + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'description' => $data['description'] ?? '', + 'visibility' => $data['visibility'], + 'created_at' => $data['created_at'], + 'web_url' => $data['web_url'], + 'ssh_url_to_repo' => $data['ssh_url_to_repo'], + 'http_url_to_repo' => $data['http_url_to_repo'], + ]; + } catch (\Exception $e) { + return 'Error creating project: '.$e->getMessage(); + } + } + + /** + * Get GitLab issues. + * + * @param int $projectId Project ID + * @param int $limit Number of issues to retrieve + * @param string $state Issue state (opened, closed, all) + * @param string $labels Comma-separated labels + * + * @return array, + * author: array{id: int, name: string, username: string}, + * assignee: array{id: int, name: string, username: string}|null, + * web_url: string, + * }> + */ + public function getIssues( + int $projectId, + int $limit = 20, + string $state = 'opened', + string $labels = '', + ): array { + try { + $params = [ + 'per_page' => min(max($limit, 1), 100), + 'state' => $state, + ]; + + if ($labels) { + $params['labels'] = $labels; + } + + $response = $this->httpClient->request('GET', "{$this->gitlabUrl}/api/{$this->apiVersion}/projects/{$projectId}/issues", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + $issues = []; + foreach ($data as $issue) { + $issues[] = [ + 'id' => $issue['id'], + 'iid' => $issue['iid'], + 'title' => $issue['title'], + 'description' => $issue['description'] ?? '', + 'state' => $issue['state'], + 'created_at' => $issue['created_at'], + 'updated_at' => $issue['updated_at'], + 'closed_at' => $issue['closed_at'] ?? null, + 'labels' => $issue['labels'] ?? [], + 'author' => [ + 'id' => $issue['author']['id'], + 'name' => $issue['author']['name'], + 'username' => $issue['author']['username'], + ], + 'assignee' => $issue['assignee'] ? [ + 'id' => $issue['assignee']['id'], + 'name' => $issue['assignee']['name'], + 'username' => $issue['assignee']['username'], + ] : null, + 'web_url' => $issue['web_url'], + ]; + } + + return $issues; + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a GitLab issue. + * + * @param int $projectId Project ID + * @param string $title Issue title + * @param string $description Issue description + * @param array $labels Issue labels + * @param int $assigneeId Assignee user ID + * + * @return array{ + * id: int, + * iid: int, + * title: string, + * description: string, + * state: string, + * created_at: string, + * labels: array, + * web_url: string, + * }|string + */ + public function createIssue( + int $projectId, + string $title, + string $description = '', + array $labels = [], + int $assigneeId = 0, + ): array|string { + try { + $payload = [ + 'title' => $title, + 'description' => $description, + 'labels' => implode(',', $labels), + ]; + + if ($assigneeId > 0) { + $payload['assignee_ids'] = [$assigneeId]; + } + + $response = $this->httpClient->request('POST', "{$this->gitlabUrl}/api/{$this->apiVersion}/projects/{$projectId}/issues", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['message'])) { + return 'Error creating issue: '.$data['message']; + } + + return [ + 'id' => $data['id'], + 'iid' => $data['iid'], + 'title' => $data['title'], + 'description' => $data['description'] ?? '', + 'state' => $data['state'], + 'created_at' => $data['created_at'], + 'labels' => $data['labels'] ?? [], + 'web_url' => $data['web_url'], + ]; + } catch (\Exception $e) { + return 'Error creating issue: '.$e->getMessage(); + } + } + + /** + * Get GitLab merge requests. + * + * @param int $projectId Project ID + * @param int $limit Number of merge requests to retrieve + * @param string $state Merge request state (opened, closed, merged, all) + * + * @return array + */ + public function getMergeRequests( + int $projectId, + int $limit = 20, + string $state = 'opened', + ): array { + try { + $params = [ + 'per_page' => min(max($limit, 1), 100), + 'state' => $state, + ]; + + $response = $this->httpClient->request('GET', "{$this->gitlabUrl}/api/{$this->apiVersion}/projects/{$projectId}/merge_requests", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + $mergeRequests = []; + foreach ($data as $mr) { + $mergeRequests[] = [ + 'id' => $mr['id'], + 'iid' => $mr['iid'], + 'title' => $mr['title'], + 'description' => $mr['description'] ?? '', + 'state' => $mr['state'], + 'created_at' => $mr['created_at'], + 'updated_at' => $mr['updated_at'], + 'merged_at' => $mr['merged_at'] ?? null, + 'source_branch' => $mr['source_branch'], + 'target_branch' => $mr['target_branch'], + 'author' => [ + 'id' => $mr['author']['id'], + 'name' => $mr['author']['name'], + 'username' => $mr['author']['username'], + ], + 'assignee' => $mr['assignee'] ? [ + 'id' => $mr['assignee']['id'], + 'name' => $mr['assignee']['name'], + 'username' => $mr['assignee']['username'], + ] : null, + 'web_url' => $mr['web_url'], + ]; + } + + return $mergeRequests; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get GitLab repository files. + * + * @param int $projectId Project ID + * @param string $filePath File path in repository + * @param string $ref Branch, tag, or commit SHA + * + * @return array{ + * file_name: string, + * file_path: string, + * size: int, + * encoding: string, + * content: string, + * content_sha256: string, + * ref: string, + * blob_id: string, + * commit_id: string, + * last_commit_id: string, + * }|string + */ + public function getRepositoryFiles( + int $projectId, + string $filePath, + string $ref = 'main', + ): array|string { + try { + $params = [ + 'ref' => $ref, + ]; + + $response = $this->httpClient->request('GET', "{$this->gitlabUrl}/api/{$this->apiVersion}/projects/{$projectId}/repository/files/".urlencode($filePath), [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => $params, + ]); + + $data = $response->toArray(); + + if (isset($data['message'])) { + return 'Error getting file: '.$data['message']; + } + + return [ + 'file_name' => $data['file_name'], + 'file_path' => $data['file_path'], + 'size' => $data['size'], + 'encoding' => $data['encoding'], + 'content' => base64_decode($data['content']), + 'content_sha256' => $data['content_sha256'], + 'ref' => $data['ref'], + 'blob_id' => $data['blob_id'], + 'commit_id' => $data['commit_id'], + 'last_commit_id' => $data['last_commit_id'], + ]; + } catch (\Exception $e) { + return 'Error getting file: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Gmail.php b/src/agent/src/Toolbox/Tool/Gmail.php new file mode 100644 index 000000000..d4a9aa53c --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Gmail.php @@ -0,0 +1,344 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('gmail_search', 'Search for messages or threads in Gmail')] +#[AsTool('gmail_send_message', 'Send email messages via Gmail', method: 'sendMessage')] +#[AsTool('gmail_get_message', 'Get a specific Gmail message by ID', method: 'getMessage')] +#[AsTool('gmail_get_thread', 'Get a specific Gmail thread by ID', method: 'getThread')] +final readonly class Gmail +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private array $options = [], + ) { + } + + /** + * Search for messages or threads in Gmail. + * + * @param string $query The Gmail query (e.g., "from:sender", "subject:subject", "is:unread") + * @param string $resource Whether to search for threads or messages (default: messages) + * @param int $maxResults The maximum number of results to return + * + * @return array + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + string $resource = 'messages', + int $maxResults = 10, + ): array { + try { + $response = $this->httpClient->request('GET', 'https://gmail.googleapis.com/gmail/v1/users/me/messages', [ + 'headers' => $this->getHeaders(), + 'query' => [ + 'q' => $query, + 'maxResults' => $maxResults, + ], + ]); + + $data = $response->toArray(); + $messages = $data['messages'] ?? []; + + if (empty($messages)) { + return []; + } + + $results = []; + foreach ($messages as $message) { + $messageDetails = $this->getMessageDetails($message['id']); + if ($messageDetails) { + $results[] = $messageDetails; + } + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'id' => 'error', + 'threadId' => 'error', + 'snippet' => 'Error: '.$e->getMessage(), + 'subject' => 'Error', + 'sender' => '', + 'date' => '', + 'to' => '', + 'cc' => null, + ], + ]; + } + } + + /** + * Send email messages via Gmail. + * + * @param string|array $to The list of recipients + * @param string $subject The subject of the message + * @param string $message The message content + * @param string|array $cc The list of CC recipients (optional) + * @param string|array $bcc The list of BCC recipients (optional) + */ + public function sendMessage( + string|array $to, + string $subject, + string $message, + string|array|null $cc = null, + string|array|null $bcc = null, + ): string { + try { + // Normalize recipients to arrays + $toArray = \is_array($to) ? $to : [$to]; + $ccArray = $cc ? (\is_array($cc) ? $cc : [$cc]) : []; + $bccArray = $bcc ? (\is_array($bcc) ? $bcc : [$bcc]) : []; + + // Create email message + $emailMessage = $this->createEmailMessage($toArray, $subject, $message, $ccArray, $bccArray); + + $response = $this->httpClient->request('POST', 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send', [ + 'headers' => $this->getHeaders(), + 'json' => ['raw' => base64_encode($emailMessage)], + ]); + + $data = $response->toArray(); + + return "Message sent successfully. Message ID: {$data['id']}"; + } catch (\Exception $e) { + return 'Error sending message: '.$e->getMessage(); + } + } + + /** + * Get a specific Gmail message by ID. + * + * @param string $messageId The Gmail message ID + * + * @return array{ + * id: string, + * threadId: string, + * snippet: string, + * body: string, + * subject: string, + * sender: string, + * date: string, + * to: string, + * cc: string|null, + * }|null + */ + public function getMessage(string $messageId): ?array + { + return $this->getMessageDetails($messageId); + } + + /** + * Get a specific Gmail thread by ID. + * + * @param string $threadId The Gmail thread ID + * + * @return array{ + * id: string, + * snippet: string, + * messages: array, + * }|null + */ + public function getThread(string $threadId): ?array + { + try { + $response = $this->httpClient->request('GET', "https://gmail.googleapis.com/gmail/v1/users/me/threads/{$threadId}", [ + 'headers' => $this->getHeaders(), + ]); + + $data = $response->toArray(); + $messages = []; + + foreach ($data['messages'] as $message) { + $messageDetails = $this->getMessageDetails($message['id']); + if ($messageDetails) { + $messages[] = [ + 'id' => $messageDetails['id'], + 'snippet' => $messageDetails['snippet'], + 'subject' => $messageDetails['subject'], + 'sender' => $messageDetails['sender'], + 'date' => $messageDetails['date'], + ]; + } + } + + return [ + 'id' => $data['id'], + 'snippet' => $data['snippet'], + 'messages' => $messages, + ]; + } catch (\Exception $e) { + return null; + } + } + + /** + * Get detailed message information. + * + * @return array{ + * id: string, + * threadId: string, + * snippet: string, + * body: string, + * subject: string, + * sender: string, + * date: string, + * to: string, + * cc: string|null, + * }|null + */ + private function getMessageDetails(string $messageId): ?array + { + try { + $response = $this->httpClient->request('GET', "https://gmail.googleapis.com/gmail/v1/users/me/messages/{$messageId}", [ + 'headers' => $this->getHeaders(), + ]); + + $data = $response->toArray(); + $headers = $this->extractHeaders($data['payload']['headers'] ?? []); + + // Extract message body + $body = $this->extractMessageBody($data['payload']); + + return [ + 'id' => $data['id'], + 'threadId' => $data['threadId'], + 'snippet' => $data['snippet'], + 'body' => $body, + 'subject' => $headers['subject'] ?? '', + 'sender' => $headers['from'] ?? '', + 'date' => $headers['date'] ?? '', + 'to' => $headers['to'] ?? '', + 'cc' => $headers['cc'] ?? null, + ]; + } catch (\Exception $e) { + return null; + } + } + + /** + * Extract headers from Gmail message. + * + * @param array $headers + * + * @return array + */ + private function extractHeaders(array $headers): array + { + $result = []; + foreach ($headers as $header) { + $result[strtolower($header['name'])] = $header['value']; + } + + return $result; + } + + /** + * Extract message body from Gmail message payload. + */ + private function extractMessageBody(array $payload): string + { + // Handle multipart messages + if (isset($payload['parts'])) { + foreach ($payload['parts'] as $part) { + if ('text/plain' === $part['mimeType'] && isset($part['body']['data'])) { + return base64_decode(str_replace(['-', '_'], ['+', '/'], $part['body']['data'])); + } + } + } + + // Handle simple messages + if (isset($payload['body']['data'])) { + return base64_decode(str_replace(['-', '_'], ['+', '/'], $payload['body']['data'])); + } + + return ''; + } + + /** + * Create email message for sending. + * + * @param array $to Recipients + * @param string $subject Subject + * @param string $message Message content + * @param array $cc CC recipients + * @param array $bcc BCC recipients + */ + private function createEmailMessage(array $to, string $subject, string $message, array $cc, array $bcc): string + { + $boundary = uniqid('boundary_'); + $headers = [ + 'To: '.implode(', ', $to), + 'Subject: '.$subject, + 'Content-Type: multipart/alternative; boundary="'.$boundary.'"', + ]; + + if (!empty($cc)) { + $headers[] = 'Cc: '.implode(', ', $cc); + } + + if (!empty($bcc)) { + $headers[] = 'Bcc: '.implode(', ', $bcc); + } + + $body = "--{$boundary}\r\n"; + $body .= "Content-Type: text/plain; charset=UTF-8\r\n\r\n"; + $body .= strip_tags($message)."\r\n\r\n"; + $body .= "--{$boundary}\r\n"; + $body .= "Content-Type: text/html; charset=UTF-8\r\n\r\n"; + $body .= $message."\r\n\r\n"; + $body .= "--{$boundary}--\r\n"; + + return implode("\r\n", $headers)."\r\n\r\n".$body; + } + + /** + * Get authentication headers. + * + * @return array + */ + private function getHeaders(): array + { + return [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ]; + } +} diff --git a/src/agent/src/Toolbox/Tool/GoldenQuery.php b/src/agent/src/Toolbox/Tool/GoldenQuery.php new file mode 100644 index 000000000..6547b5ed1 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GoldenQuery.php @@ -0,0 +1,825 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('golden_query_execute', 'Tool that executes SQL queries using Golden Query')] +#[AsTool('golden_query_explain', 'Tool that explains SQL queries', method: 'explain')] +#[AsTool('golden_query_optimize', 'Tool that optimizes SQL queries', method: 'optimize')] +#[AsTool('golden_query_schema', 'Tool that retrieves database schema', method: 'getSchema')] +#[AsTool('golden_query_validate', 'Tool that validates SQL queries', method: 'validate')] +#[AsTool('golden_query_convert', 'Tool that converts SQL between dialects', method: 'convert')] +#[AsTool('golden_query_analyze', 'Tool that analyzes query performance', method: 'analyze')] +#[AsTool('golden_query_suggest', 'Tool that suggests query improvements', method: 'suggest')] +final readonly class GoldenQuery +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.goldenquery.com/v1', + private array $options = [], + ) { + } + + /** + * Execute SQL query using Golden Query. + * + * @param string $query SQL query to execute + * @param string $databaseType Database type (mysql, postgresql, sqlite, mssql) + * @param array $parameters Query parameters + * @param bool $explain Whether to include query explanation + * + * @return array{ + * success: bool, + * execution: array{ + * query: string, + * database_type: string, + * result: array>, + * columns: array, + * row_count: int, + * execution_time: float, + * explain_plan: array, + * warnings: array, + * parameters: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $query, + string $databaseType = 'mysql', + array $parameters = [], + bool $explain = false, + ): array { + try { + $requestData = [ + 'query' => $query, + 'database_type' => $databaseType, + 'parameters' => $parameters, + 'explain' => $explain, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/query/execute", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $execution = $data['execution'] ?? []; + + return [ + 'success' => true, + 'execution' => [ + 'query' => $query, + 'database_type' => $databaseType, + 'result' => $execution['result'] ?? [], + 'columns' => array_map(fn ($column) => [ + 'name' => $column['name'] ?? '', + 'type' => $column['type'] ?? '', + 'nullable' => $column['nullable'] ?? false, + ], $execution['columns'] ?? []), + 'row_count' => $execution['row_count'] ?? 0, + 'execution_time' => $execution['execution_time'] ?? 0.0, + 'explain_plan' => $explain ? ($execution['explain_plan'] ?? []) : [], + 'warnings' => $execution['warnings'] ?? [], + 'parameters' => $parameters, + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'execution' => [ + 'query' => $query, + 'database_type' => $databaseType, + 'result' => [], + 'columns' => [], + 'row_count' => 0, + 'execution_time' => 0.0, + 'explain_plan' => [], + 'warnings' => [], + 'parameters' => $parameters, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Explain SQL query. + * + * @param string $query SQL query to explain + * @param string $databaseType Database type + * @param string $explainType Type of explanation (detailed, simple, performance) + * + * @return array{ + * success: bool, + * explanation: array{ + * query: string, + * database_type: string, + * explain_type: string, + * explanation: string, + * steps: array, + * complexity: string, + * recommendations: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function explain( + string $query, + string $databaseType = 'mysql', + string $explainType = 'detailed', + ): array { + try { + $requestData = [ + 'query' => $query, + 'database_type' => $databaseType, + 'explain_type' => $explainType, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/query/explain", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $explanation = $data['explanation'] ?? []; + + return [ + 'success' => true, + 'explanation' => [ + 'query' => $query, + 'database_type' => $databaseType, + 'explain_type' => $explainType, + 'explanation' => $explanation['explanation'] ?? '', + 'steps' => array_map(fn ($step) => [ + 'step' => $step['step'] ?? 0, + 'operation' => $step['operation'] ?? '', + 'description' => $step['description'] ?? '', + 'cost' => $step['cost'] ?? 0.0, + 'rows' => $step['rows'] ?? 0, + ], $explanation['steps'] ?? []), + 'complexity' => $explanation['complexity'] ?? 'unknown', + 'recommendations' => $explanation['recommendations'] ?? [], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'explanation' => [ + 'query' => $query, + 'database_type' => $databaseType, + 'explain_type' => $explainType, + 'explanation' => '', + 'steps' => [], + 'complexity' => 'unknown', + 'recommendations' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Optimize SQL query. + * + * @param string $query SQL query to optimize + * @param string $databaseType Database type + * @param array $optimizationOptions Optimization options + * + * @return array{ + * success: bool, + * optimization: array{ + * original_query: string, + * optimized_query: string, + * database_type: string, + * improvements: array, + * performance_estimate: array{ + * original_time: float, + * optimized_time: float, + * improvement_percentage: float, + * }, + * warnings: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function optimize( + string $query, + string $databaseType = 'mysql', + array $optimizationOptions = [], + ): array { + try { + $requestData = [ + 'query' => $query, + 'database_type' => $databaseType, + 'optimization_options' => $optimizationOptions, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/query/optimize", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $optimization = $data['optimization'] ?? []; + + return [ + 'success' => true, + 'optimization' => [ + 'original_query' => $query, + 'optimized_query' => $optimization['optimized_query'] ?? '', + 'database_type' => $databaseType, + 'improvements' => array_map(fn ($improvement) => [ + 'type' => $improvement['type'] ?? '', + 'description' => $improvement['description'] ?? '', + 'impact' => $improvement['impact'] ?? '', + 'before' => $improvement['before'] ?? '', + 'after' => $improvement['after'] ?? '', + ], $optimization['improvements'] ?? []), + 'performance_estimate' => [ + 'original_time' => $optimization['performance_estimate']['original_time'] ?? 0.0, + 'optimized_time' => $optimization['performance_estimate']['optimized_time'] ?? 0.0, + 'improvement_percentage' => $optimization['performance_estimate']['improvement_percentage'] ?? 0.0, + ], + 'warnings' => $optimization['warnings'] ?? [], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'optimization' => [ + 'original_query' => $query, + 'optimized_query' => '', + 'database_type' => $databaseType, + 'improvements' => [], + 'performance_estimate' => [ + 'original_time' => 0.0, + 'optimized_time' => 0.0, + 'improvement_percentage' => 0.0, + ], + 'warnings' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get database schema. + * + * @param string $databaseType Database type + * @param string $schemaName Schema name (optional) + * + * @return array{ + * success: bool, + * schema: array{ + * database_type: string, + * schema_name: string, + * tables: array, + * indexes: array, + * unique: bool, + * }>, + * row_count: int, + * }>, + * relationships: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function getSchema( + string $databaseType = 'mysql', + string $schemaName = '', + ): array { + try { + $requestData = [ + 'database_type' => $databaseType, + 'schema_name' => $schemaName, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/schema", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $schema = $data['schema'] ?? []; + + return [ + 'success' => true, + 'schema' => [ + 'database_type' => $databaseType, + 'schema_name' => $schema['schema_name'] ?? $schemaName, + 'tables' => array_map(fn ($table) => [ + 'name' => $table['name'] ?? '', + 'columns' => array_map(fn ($column) => [ + 'name' => $column['name'] ?? '', + 'type' => $column['type'] ?? '', + 'nullable' => $column['nullable'] ?? false, + 'default_value' => $column['default_value'] ?? null, + 'primary_key' => $column['primary_key'] ?? false, + 'foreign_key' => $column['foreign_key'] ?? null, + ], $table['columns'] ?? []), + 'indexes' => array_map(fn ($index) => [ + 'name' => $index['name'] ?? '', + 'columns' => $index['columns'] ?? [], + 'unique' => $index['unique'] ?? false, + ], $table['indexes'] ?? []), + 'row_count' => $table['row_count'] ?? 0, + ], $schema['tables'] ?? []), + 'relationships' => array_map(fn ($rel) => [ + 'from_table' => $rel['from_table'] ?? '', + 'from_column' => $rel['from_column'] ?? '', + 'to_table' => $rel['to_table'] ?? '', + 'to_column' => $rel['to_column'] ?? '', + 'type' => $rel['type'] ?? '', + ], $schema['relationships'] ?? []), + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'schema' => [ + 'database_type' => $databaseType, + 'schema_name' => $schemaName, + 'tables' => [], + 'relationships' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Validate SQL query. + * + * @param string $query SQL query to validate + * @param string $databaseType Database type + * @param bool $strict Whether to use strict validation + * + * @return array{ + * success: bool, + * validation: array{ + * query: string, + * database_type: string, + * is_valid: bool, + * errors: array, + * warnings: array, + * syntax_score: float, + * performance_score: float, + * }, + * processingTime: float, + * error: string, + * } + */ + public function validate( + string $query, + string $databaseType = 'mysql', + bool $strict = false, + ): array { + try { + $requestData = [ + 'query' => $query, + 'database_type' => $databaseType, + 'strict' => $strict, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/query/validate", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $validation = $data['validation'] ?? []; + + return [ + 'success' => true, + 'validation' => [ + 'query' => $query, + 'database_type' => $databaseType, + 'is_valid' => $validation['is_valid'] ?? false, + 'errors' => array_map(fn ($error) => [ + 'line' => $error['line'] ?? 0, + 'column' => $error['column'] ?? 0, + 'message' => $error['message'] ?? '', + 'severity' => $error['severity'] ?? 'error', + ], $validation['errors'] ?? []), + 'warnings' => array_map(fn ($warning) => [ + 'line' => $warning['line'] ?? 0, + 'column' => $warning['column'] ?? 0, + 'message' => $warning['message'] ?? '', + 'severity' => $warning['severity'] ?? 'warning', + ], $validation['warnings'] ?? []), + 'syntax_score' => $validation['syntax_score'] ?? 0.0, + 'performance_score' => $validation['performance_score'] ?? 0.0, + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'validation' => [ + 'query' => $query, + 'database_type' => $databaseType, + 'is_valid' => false, + 'errors' => [], + 'warnings' => [], + 'syntax_score' => 0.0, + 'performance_score' => 0.0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Convert SQL between dialects. + * + * @param string $query SQL query to convert + * @param string $fromDialect Source SQL dialect + * @param string $toDialect Target SQL dialect + * @param bool $preserveSemantics Whether to preserve query semantics + * + * @return array{ + * success: bool, + * conversion: array{ + * original_query: string, + * converted_query: string, + * from_dialect: string, + * to_dialect: string, + * changes: array, + * compatibility_score: float, + * warnings: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function convert( + string $query, + string $fromDialect = 'mysql', + string $toDialect = 'postgresql', + bool $preserveSemantics = true, + ): array { + try { + $requestData = [ + 'query' => $query, + 'from_dialect' => $fromDialect, + 'to_dialect' => $toDialect, + 'preserve_semantics' => $preserveSemantics, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/query/convert", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $conversion = $data['conversion'] ?? []; + + return [ + 'success' => true, + 'conversion' => [ + 'original_query' => $query, + 'converted_query' => $conversion['converted_query'] ?? '', + 'from_dialect' => $fromDialect, + 'to_dialect' => $toDialect, + 'changes' => array_map(fn ($change) => [ + 'type' => $change['type'] ?? '', + 'original' => $change['original'] ?? '', + 'converted' => $change['converted'] ?? '', + 'reason' => $change['reason'] ?? '', + ], $conversion['changes'] ?? []), + 'compatibility_score' => $conversion['compatibility_score'] ?? 0.0, + 'warnings' => $conversion['warnings'] ?? [], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'conversion' => [ + 'original_query' => $query, + 'converted_query' => '', + 'from_dialect' => $fromDialect, + 'to_dialect' => $toDialect, + 'changes' => [], + 'compatibility_score' => 0.0, + 'warnings' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze query performance. + * + * @param string $query SQL query to analyze + * @param string $databaseType Database type + * @param array $analysisOptions Analysis options + * + * @return array{ + * success: bool, + * analysis: array{ + * query: string, + * database_type: string, + * performance_metrics: array{ + * execution_time: float, + * cost: float, + * rows_examined: int, + * rows_returned: int, + * io_operations: int, + * }, + * bottlenecks: array, + * recommendations: array, + * complexity_analysis: array{ + * time_complexity: string, + * space_complexity: string, + * join_complexity: string, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function analyze( + string $query, + string $databaseType = 'mysql', + array $analysisOptions = [], + ): array { + try { + $requestData = [ + 'query' => $query, + 'database_type' => $databaseType, + 'analysis_options' => $analysisOptions, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/query/analyze", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $analysis = $data['analysis'] ?? []; + + return [ + 'success' => true, + 'analysis' => [ + 'query' => $query, + 'database_type' => $databaseType, + 'performance_metrics' => [ + 'execution_time' => $analysis['performance_metrics']['execution_time'] ?? 0.0, + 'cost' => $analysis['performance_metrics']['cost'] ?? 0.0, + 'rows_examined' => $analysis['performance_metrics']['rows_examined'] ?? 0, + 'rows_returned' => $analysis['performance_metrics']['rows_returned'] ?? 0, + 'io_operations' => $analysis['performance_metrics']['io_operations'] ?? 0, + ], + 'bottlenecks' => array_map(fn ($bottleneck) => [ + 'type' => $bottleneck['type'] ?? '', + 'description' => $bottleneck['description'] ?? '', + 'impact' => $bottleneck['impact'] ?? '', + 'suggestion' => $bottleneck['suggestion'] ?? '', + ], $analysis['bottlenecks'] ?? []), + 'recommendations' => $analysis['recommendations'] ?? [], + 'complexity_analysis' => [ + 'time_complexity' => $analysis['complexity_analysis']['time_complexity'] ?? 'unknown', + 'space_complexity' => $analysis['complexity_analysis']['space_complexity'] ?? 'unknown', + 'join_complexity' => $analysis['complexity_analysis']['join_complexity'] ?? 'unknown', + ], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'analysis' => [ + 'query' => $query, + 'database_type' => $databaseType, + 'performance_metrics' => [ + 'execution_time' => 0.0, + 'cost' => 0.0, + 'rows_examined' => 0, + 'rows_returned' => 0, + 'io_operations' => 0, + ], + 'bottlenecks' => [], + 'recommendations' => [], + 'complexity_analysis' => [ + 'time_complexity' => 'unknown', + 'space_complexity' => 'unknown', + 'join_complexity' => 'unknown', + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Suggest query improvements. + * + * @param string $query SQL query to improve + * @param string $databaseType Database type + * @param array $context Context information + * + * @return array{ + * success: bool, + * suggestions: array{ + * query: string, + * database_type: string, + * improvements: array, + * alternative_queries: array, + * }>, + * best_practices: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function suggest( + string $query, + string $databaseType = 'mysql', + array $context = [], + ): array { + try { + $requestData = [ + 'query' => $query, + 'database_type' => $databaseType, + 'context' => $context, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/query/suggest", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $suggestions = $data['suggestions'] ?? []; + + return [ + 'success' => true, + 'suggestions' => [ + 'query' => $query, + 'database_type' => $databaseType, + 'improvements' => array_map(fn ($improvement) => [ + 'type' => $improvement['type'] ?? '', + 'description' => $improvement['description'] ?? '', + 'priority' => $improvement['priority'] ?? 'medium', + 'impact' => $improvement['impact'] ?? '', + 'example' => $improvement['example'] ?? '', + ], $suggestions['improvements'] ?? []), + 'alternative_queries' => array_map(fn ($alt) => [ + 'query' => $alt['query'] ?? '', + 'description' => $alt['description'] ?? '', + 'advantages' => $alt['advantages'] ?? [], + ], $suggestions['alternative_queries'] ?? []), + 'best_practices' => $suggestions['best_practices'] ?? [], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'suggestions' => [ + 'query' => $query, + 'database_type' => $databaseType, + 'improvements' => [], + 'alternative_queries' => [], + 'best_practices' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/GoogleBooks.php b/src/agent/src/Toolbox/Tool/GoogleBooks.php new file mode 100644 index 000000000..b0f164b1f --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GoogleBooks.php @@ -0,0 +1,285 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('google_books', 'Tool that searches the Google Books API')] +#[AsTool('google_books_detailed', 'Tool that gets detailed book information', method: 'getBookDetails')] +final readonly class GoogleBooks +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private array $options = [], + ) { + } + + /** + * Search Google Books for books. + * + * @param string $query Query to look up on Google Books + * @param int $maxResults Maximum number of results to return + * @param string $orderBy Sort order: relevance, newest + * @param string $printType Print type filter: all, books, magazines + * + * @return array, + * publisher: string, + * published_date: string, + * description: string, + * isbn_13: string, + * isbn_10: string, + * page_count: int, + * categories: array, + * language: string, + * preview_link: string, + * info_link: string, + * thumbnail_url: string, + * average_rating: float, + * ratings_count: int, + * }> + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + int $maxResults = 10, + string $orderBy = 'relevance', + string $printType = 'all', + ): array { + try { + $response = $this->httpClient->request('GET', 'https://www.googleapis.com/books/v1/volumes', [ + 'query' => array_merge($this->options, [ + 'q' => $query, + 'maxResults' => $maxResults, + 'orderBy' => $orderBy, + 'printType' => $printType, + 'key' => $this->apiKey, + ]), + ]); + + $data = $response->toArray(); + + if (!isset($data['items'])) { + return []; + } + + $results = []; + foreach ($data['items'] as $item) { + $volumeInfo = $item['volumeInfo']; + $saleInfo = $item['saleInfo'] ?? []; + + $results[] = [ + 'book_id' => $item['id'], + 'title' => $volumeInfo['title'] ?? '', + 'authors' => $volumeInfo['authors'] ?? [], + 'publisher' => $volumeInfo['publisher'] ?? '', + 'published_date' => $volumeInfo['publishedDate'] ?? '', + 'description' => $volumeInfo['description'] ?? '', + 'isbn_13' => $this->extractIsbn($volumeInfo['industryIdentifiers'] ?? [], 'ISBN_13'), + 'isbn_10' => $this->extractIsbn($volumeInfo['industryIdentifiers'] ?? [], 'ISBN_10'), + 'page_count' => $volumeInfo['pageCount'] ?? 0, + 'categories' => $volumeInfo['categories'] ?? [], + 'language' => $volumeInfo['language'] ?? '', + 'preview_link' => $volumeInfo['previewLink'] ?? '', + 'info_link' => $volumeInfo['infoLink'] ?? '', + 'thumbnail_url' => $volumeInfo['imageLinks']['thumbnail'] ?? '', + 'average_rating' => (float) ($volumeInfo['averageRating'] ?? 0), + 'ratings_count' => (int) ($volumeInfo['ratingsCount'] ?? 0), + ]; + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'book_id' => 'error', + 'title' => 'Search Error', + 'authors' => [], + 'publisher' => '', + 'published_date' => '', + 'description' => 'Unable to search Google Books: '.$e->getMessage(), + 'isbn_13' => '', + 'isbn_10' => '', + 'page_count' => 0, + 'categories' => [], + 'language' => '', + 'preview_link' => '', + 'info_link' => '', + 'thumbnail_url' => '', + 'average_rating' => 0.0, + 'ratings_count' => 0, + ], + ]; + } + } + + /** + * Get detailed information for a specific book. + * + * @param string $bookId The Google Books ID + * + * @return array{ + * book_id: string, + * title: string, + * authors: array, + * publisher: string, + * published_date: string, + * description: string, + * isbn_13: string, + * isbn_10: string, + * page_count: int, + * categories: array, + * language: string, + * preview_link: string, + * info_link: string, + * thumbnail_url: string, + * average_rating: float, + * ratings_count: int, + * maturity_rating: string, + * subtitle: string, + * series_info: array, + * dimensions: array, + * price: array, + * availability: string, + * }|null + */ + public function getBookDetails(string $bookId): ?array + { + try { + $response = $this->httpClient->request('GET', "https://www.googleapis.com/books/v1/volumes/{$bookId}", [ + 'query' => [ + 'key' => $this->apiKey, + ], + ]); + + $data = $response->toArray(); + + if (!isset($data['volumeInfo'])) { + return null; + } + + $volumeInfo = $data['volumeInfo']; + $saleInfo = $data['saleInfo'] ?? []; + + return [ + 'book_id' => $data['id'], + 'title' => $volumeInfo['title'] ?? '', + 'authors' => $volumeInfo['authors'] ?? [], + 'publisher' => $volumeInfo['publisher'] ?? '', + 'published_date' => $volumeInfo['publishedDate'] ?? '', + 'description' => $volumeInfo['description'] ?? '', + 'isbn_13' => $this->extractIsbn($volumeInfo['industryIdentifiers'] ?? [], 'ISBN_13'), + 'isbn_10' => $this->extractIsbn($volumeInfo['industryIdentifiers'] ?? [], 'ISBN_10'), + 'page_count' => $volumeInfo['pageCount'] ?? 0, + 'categories' => $volumeInfo['categories'] ?? [], + 'language' => $volumeInfo['language'] ?? '', + 'preview_link' => $volumeInfo['previewLink'] ?? '', + 'info_link' => $volumeInfo['infoLink'] ?? '', + 'thumbnail_url' => $volumeInfo['imageLinks']['thumbnail'] ?? '', + 'average_rating' => (float) ($volumeInfo['averageRating'] ?? 0), + 'ratings_count' => (int) ($volumeInfo['ratingsCount'] ?? 0), + 'maturity_rating' => $volumeInfo['maturityRating'] ?? '', + 'subtitle' => $volumeInfo['subtitle'] ?? '', + 'series_info' => $this->extractSeriesInfo($volumeInfo), + 'dimensions' => $volumeInfo['dimensions'] ?? [], + 'price' => $saleInfo['listPrice'] ?? [], + 'availability' => $saleInfo['saleability'] ?? '', + ]; + } catch (\Exception $e) { + return null; + } + } + + /** + * Search books by author. + * + * @param string $author Author name + * @param int $maxResults Maximum number of results to return + * + * @return array> + */ + public function searchByAuthor(string $author, int $maxResults = 10): array + { + return $this->__invoke("inauthor:{$author}", $maxResults); + } + + /** + * Search books by title. + * + * @param string $title Book title + * @param int $maxResults Maximum number of results to return + * + * @return array> + */ + public function searchByTitle(string $title, int $maxResults = 10): array + { + return $this->__invoke("intitle:{$title}", $maxResults); + } + + /** + * Search books by subject. + * + * @param string $subject Subject or category + * @param int $maxResults Maximum number of results to return + * + * @return array> + */ + public function searchBySubject(string $subject, int $maxResults = 10): array + { + return $this->__invoke("subject:{$subject}", $maxResults); + } + + /** + * Extract ISBN from industry identifiers. + * + * @param array> $identifiers + */ + private function extractIsbn(array $identifiers, string $type): string + { + foreach ($identifiers as $identifier) { + if ($identifier['type'] === $type) { + return $identifier['identifier']; + } + } + + return ''; + } + + /** + * Extract series information from volume info. + * + * @param array $volumeInfo + * + * @return array + */ + private function extractSeriesInfo(array $volumeInfo): array + { + $seriesInfo = []; + + if (isset($volumeInfo['seriesInfo'])) { + $seriesInfo = $volumeInfo['seriesInfo']; + } + + return $seriesInfo; + } +} diff --git a/src/agent/src/Toolbox/Tool/GoogleCalendar.php b/src/agent/src/Toolbox/Tool/GoogleCalendar.php new file mode 100644 index 000000000..63b3e1145 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GoogleCalendar.php @@ -0,0 +1,373 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('google_calendar_list_events', 'Tool that lists events from Google Calendar')] +#[AsTool('google_calendar_create_event', 'Tool that creates events in Google Calendar', method: 'createEvent')] +#[AsTool('google_calendar_update_event', 'Tool that updates events in Google Calendar', method: 'updateEvent')] +#[AsTool('google_calendar_delete_event', 'Tool that deletes events from Google Calendar', method: 'deleteEvent')] +#[AsTool('google_calendar_list_calendars', 'Tool that lists Google Calendars', method: 'listCalendars')] +final readonly class GoogleCalendar +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private string $calendarId = 'primary', + private array $options = [], + ) { + } + + /** + * List events from Google Calendar. + * + * @param string $timeMin Lower bound (exclusive) for an event's end time to filter by + * @param string $timeMax Upper bound (exclusive) for an event's start time to filter by + * @param int $maxResults Maximum number of events to return + * @param string $orderBy Order of the events returned (startTime, updated) + * @param bool $singleEvents Whether to expand recurring events into instances + * + * @return array, + * creator: array{email: string, displayName: string}, + * organizer: array{email: string, displayName: string}, + * htmlLink: string, + * status: string, + * transparency: string, + * visibility: string, + * }> + */ + public function __invoke( + string $timeMin = '', + string $timeMax = '', + int $maxResults = 10, + string $orderBy = 'startTime', + bool $singleEvents = true, + ): array { + try { + $params = [ + 'maxResults' => $maxResults, + 'singleEvents' => $singleEvents, + 'orderBy' => $orderBy, + ]; + + if ($timeMin) { + $params['timeMin'] = $timeMin; + } + if ($timeMax) { + $params['timeMax'] = $timeMax; + } + + $response = $this->httpClient->request('GET', "https://www.googleapis.com/calendar/v3/calendars/{$this->calendarId}/events", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['items'])) { + return []; + } + + $events = []; + foreach ($data['items'] as $event) { + $events[] = [ + 'id' => $event['id'], + 'summary' => $event['summary'] ?? '', + 'description' => $event['description'] ?? '', + 'start' => [ + 'dateTime' => $event['start']['dateTime'] ?? $event['start']['date'] ?? '', + 'timeZone' => $event['start']['timeZone'] ?? 'UTC', + ], + 'end' => [ + 'dateTime' => $event['end']['dateTime'] ?? $event['end']['date'] ?? '', + 'timeZone' => $event['end']['timeZone'] ?? 'UTC', + ], + 'location' => $event['location'] ?? '', + 'attendees' => array_map(fn ($attendee) => [ + 'email' => $attendee['email'], + 'displayName' => $attendee['displayName'] ?? '', + 'responseStatus' => $attendee['responseStatus'] ?? 'needsAction', + ], $event['attendees'] ?? []), + 'creator' => [ + 'email' => $event['creator']['email'], + 'displayName' => $event['creator']['displayName'] ?? '', + ], + 'organizer' => [ + 'email' => $event['organizer']['email'], + 'displayName' => $event['organizer']['displayName'] ?? '', + ], + 'htmlLink' => $event['htmlLink'], + 'status' => $event['status'] ?? 'confirmed', + 'transparency' => $event['transparency'] ?? 'opaque', + 'visibility' => $event['visibility'] ?? 'default', + ]; + } + + return $events; + } catch (\Exception $e) { + return []; + } + } + + /** + * Create an event in Google Calendar. + * + * @param string $summary Event title/summary + * @param string $startTime Event start time (ISO 8601 format) + * @param string $endTime Event end time (ISO 8601 format) + * @param string $description Event description + * @param string $location Event location + * @param array $attendees List of attendee email addresses + * @param string $timeZone Time zone for the event + * + * @return array{ + * id: string, + * summary: string, + * description: string, + * start: array{dateTime: string, timeZone: string}, + * end: array{dateTime: string, timeZone: string}, + * location: string, + * attendees: array, + * htmlLink: string, + * status: string, + * }|string + */ + public function createEvent( + string $summary, + string $startTime, + string $endTime, + string $description = '', + string $location = '', + array $attendees = [], + string $timeZone = 'UTC', + ): array|string { + try { + $eventData = [ + 'summary' => $summary, + 'description' => $description, + 'location' => $location, + 'start' => [ + 'dateTime' => $startTime, + 'timeZone' => $timeZone, + ], + 'end' => [ + 'dateTime' => $endTime, + 'timeZone' => $timeZone, + ], + ]; + + if (!empty($attendees)) { + $eventData['attendees'] = array_map(fn ($email) => [ + 'email' => $email, + ], $attendees); + } + + $response = $this->httpClient->request('POST', "https://www.googleapis.com/calendar/v3/calendars/{$this->calendarId}/events", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $eventData, + ]); + + $data = $response->toArray(); + + return [ + 'id' => $data['id'], + 'summary' => $data['summary'], + 'description' => $data['description'] ?? '', + 'start' => [ + 'dateTime' => $data['start']['dateTime'], + 'timeZone' => $data['start']['timeZone'], + ], + 'end' => [ + 'dateTime' => $data['end']['dateTime'], + 'timeZone' => $data['end']['timeZone'], + ], + 'location' => $data['location'] ?? '', + 'attendees' => array_map(fn ($attendee) => [ + 'email' => $attendee['email'], + 'responseStatus' => $attendee['responseStatus'] ?? 'needsAction', + ], $data['attendees'] ?? []), + 'htmlLink' => $data['htmlLink'], + 'status' => $data['status'], + ]; + } catch (\Exception $e) { + return 'Error creating event: '.$e->getMessage(); + } + } + + /** + * Update an existing event in Google Calendar. + * + * @param string $eventId Event ID to update + * @param array $updates Fields to update + * + * @return array|string + */ + public function updateEvent(string $eventId, array $updates): array|string + { + try { + $response = $this->httpClient->request('PUT', "https://www.googleapis.com/calendar/v3/calendars/{$this->calendarId}/events/{$eventId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $updates, + ]); + + return $response->toArray(); + } catch (\Exception $e) { + return 'Error updating event: '.$e->getMessage(); + } + } + + /** + * Delete an event from Google Calendar. + * + * @param string $eventId Event ID to delete + */ + public function deleteEvent(string $eventId): string + { + try { + $response = $this->httpClient->request('DELETE', "https://www.googleapis.com/calendar/v3/calendars/{$this->calendarId}/events/{$eventId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + ]); + + if (204 === $response->getStatusCode()) { + return "Event {$eventId} deleted successfully"; + } else { + return 'Failed to delete event'; + } + } catch (\Exception $e) { + return 'Error deleting event: '.$e->getMessage(); + } + } + + /** + * List Google Calendars. + * + * @return array + */ + public function listCalendars(): array + { + try { + $response = $this->httpClient->request('GET', 'https://www.googleapis.com/calendar/v3/users/me/calendarList', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + ]); + + $data = $response->toArray(); + + if (!isset($data['items'])) { + return []; + } + + $calendars = []; + foreach ($data['items'] as $calendar) { + $calendars[] = [ + 'id' => $calendar['id'], + 'summary' => $calendar['summary'], + 'description' => $calendar['description'] ?? '', + 'timeZone' => $calendar['timeZone'] ?? 'UTC', + 'accessRole' => $calendar['accessRole'], + 'backgroundColor' => $calendar['backgroundColor'] ?? '#1a73e8', + 'foregroundColor' => $calendar['foregroundColor'] ?? '#ffffff', + 'selected' => $calendar['selected'] ?? false, + 'primary' => $calendar['primary'] ?? false, + ]; + } + + return $calendars; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get free/busy information for a time range. + * + * @param string $timeMin Start time for the query (ISO 8601 format) + * @param string $timeMax End time for the query (ISO 8601 format) + * @param array $calendarIds List of calendar IDs to check + * + * @return array{ + * calendars: array, + * }>, + * timeMin: string, + * timeMax: string, + * }|string + */ + public function getFreeBusy(string $timeMin, string $timeMax, array $calendarIds = []): array|string + { + try { + $requestBody = [ + 'timeMin' => $timeMin, + 'timeMax' => $timeMax, + 'items' => array_map(fn ($id) => ['id' => $id], $calendarIds ?: [$this->calendarId]), + ]; + + $response = $this->httpClient->request('POST', 'https://www.googleapis.com/calendar/v3/freeBusy', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $requestBody, + ]); + + $data = $response->toArray(); + + return [ + 'calendars' => $data['calendars'], + 'timeMin' => $data['timeMin'], + 'timeMax' => $data['timeMax'], + ]; + } catch (\Exception $e) { + return 'Error getting free/busy information: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/GoogleCloud.php b/src/agent/src/Toolbox/Tool/GoogleCloud.php new file mode 100644 index 000000000..5d64eb070 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GoogleCloud.php @@ -0,0 +1,426 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('gcp_compute_list_instances', 'Tool that lists Google Cloud Compute instances')] +#[AsTool('gcp_storage_list_buckets', 'Tool that lists Google Cloud Storage buckets', method: 'listBuckets')] +#[AsTool('gcp_sql_list_instances', 'Tool that lists Google Cloud SQL instances', method: 'listSqlInstances')] +#[AsTool('gcp_app_engine_list_services', 'Tool that lists Google App Engine services', method: 'listAppEngineServices')] +#[AsTool('gcp_functions_list', 'Tool that lists Google Cloud Functions', method: 'listFunctions')] +#[AsTool('gcp_iam_list_service_accounts', 'Tool that lists Google Cloud IAM service accounts', method: 'listServiceAccounts')] +final readonly class GoogleCloud +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken = '', + private string $projectId = '', + private string $apiVersion = 'v1', + private array $options = [], + ) { + } + + /** + * List Google Cloud Compute instances. + * + * @param string $zone Zone name (optional) + * @param string $filter Filter expression + * + * @return array, + * }>, + * tags: array{ + * items: array, + * }, + * labels: array, + * }> + */ + public function __invoke( + string $zone = '', + string $filter = '', + ): array { + try { + $params = []; + + if ($filter) { + $params['filter'] = $filter; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->accessToken) { + $headers['Authorization'] = 'Bearer '.$this->accessToken; + } + + $url = $zone + ? "https://compute.googleapis.com/compute/{$this->apiVersion}/projects/{$this->projectId}/zones/{$zone}/instances" + : "https://compute.googleapis.com/compute/{$this->apiVersion}/projects/{$this->projectId}/aggregated/instances"; + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + $instances = []; + if ($zone) { + $instances = $data['items'] ?? []; + } else { + foreach ($data['items'] ?? [] as $zoneData) { + if (isset($zoneData['instances'])) { + $instances = array_merge($instances, $zoneData['instances']); + } + } + } + + return array_map(fn ($instance) => [ + 'id' => $instance['id'], + 'name' => $instance['name'], + 'machineType' => basename($instance['machineType']), + 'status' => $instance['status'], + 'zone' => basename($instance['zone']), + 'creationTimestamp' => $instance['creationTimestamp'], + 'networkInterfaces' => array_map(fn ($nic) => [ + 'network' => basename($nic['network']), + 'networkIP' => $nic['networkIP'], + 'accessConfigs' => array_map(fn ($config) => [ + 'natIP' => $config['natIP'] ?? '', + ], $nic['accessConfigs'] ?? []), + ], $instance['networkInterfaces'] ?? []), + 'tags' => [ + 'items' => $instance['tags']['items'] ?? [], + ], + 'labels' => $instance['labels'] ?? [], + ], $instances); + } catch (\Exception $e) { + return []; + } + } + + /** + * List Google Cloud Storage buckets. + * + * @param string $project Project ID (optional) + * + * @return array, + * }> + */ + public function listBuckets(string $project = ''): array + { + try { + $projectId = $project ?: $this->projectId; + + $headers = ['Content-Type' => 'application/json']; + if ($this->accessToken) { + $headers['Authorization'] = 'Bearer '.$this->accessToken; + } + + $response = $this->httpClient->request('GET', "https://storage.googleapis.com/storage/{$this->apiVersion}/b", [ + 'headers' => $headers, + 'query' => array_merge($this->options, ['project' => $projectId]), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($bucket) => [ + 'id' => $bucket['id'], + 'name' => $bucket['name'], + 'timeCreated' => $bucket['timeCreated'], + 'updated' => $bucket['updated'], + 'location' => $bucket['location'], + 'storageClass' => $bucket['storageClass'], + 'labels' => $bucket['labels'] ?? [], + ], $data['items'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * List Google Cloud SQL instances. + * + * @param string $filter Filter expression + * + * @return array, + * settings: array{ + * tier: string, + * dataDiskSizeGb: int, + * dataDiskType: string, + * ipConfiguration: array, + * }, + * labels: array, + * }> + */ + public function listSqlInstances(string $filter = ''): array + { + try { + $params = []; + + if ($filter) { + $params['filter'] = $filter; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->accessToken) { + $headers['Authorization'] = 'Bearer '.$this->accessToken; + } + + $response = $this->httpClient->request('GET', "https://sqladmin.googleapis.com/sql/{$this->apiVersion}/projects/{$this->projectId}/instances", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($instance) => [ + 'name' => $instance['name'], + 'instanceType' => $instance['instanceType'], + 'state' => $instance['state'], + 'databaseVersion' => $instance['databaseVersion'], + 'region' => $instance['region'], + 'ipAddresses' => array_map(fn ($ip) => [ + 'ipAddress' => $ip['ipAddress'], + 'type' => $ip['type'], + ], $instance['ipAddresses'] ?? []), + 'settings' => [ + 'tier' => $instance['settings']['tier'], + 'dataDiskSizeGb' => $instance['settings']['dataDiskSizeGb'], + 'dataDiskType' => $instance['settings']['dataDiskType'], + 'ipConfiguration' => $instance['settings']['ipConfiguration'] ?? [], + ], + 'labels' => $instance['labels'] ?? [], + ], $data['items'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * List Google App Engine services. + * + * @return array, + * }, + * networkSettings: array|null, + * labels: array, + * }> + */ + public function listAppEngineServices(): array + { + try { + $headers = ['Content-Type' => 'application/json']; + if ($this->accessToken) { + $headers['Authorization'] = 'Bearer '.$this->accessToken; + } + + $response = $this->httpClient->request('GET', "https://appengine.googleapis.com/{$this->apiVersion}/apps/{$this->projectId}/services", [ + 'headers' => $headers, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($service) => [ + 'id' => $service['id'], + 'name' => $service['name'], + 'split' => [ + 'allocationPolicy' => $service['split']['allocationPolicy'] ?? [], + ], + 'networkSettings' => $service['networkSettings'] ?? null, + 'labels' => $service['labels'] ?? [], + ], $data['services'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * List Google Cloud Functions. + * + * @param string $location Location filter + * + * @return array, + * sourceArchiveUrl: string, + * sourceUploadUrl: string, + * sourceRepository: array|null, + * httpsTrigger: array|null, + * eventTrigger: array|null, + * }> + */ + public function listFunctions(string $location = ''): array + { + try { + $params = []; + + if ($location) { + $params['location'] = $location; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->accessToken) { + $headers['Authorization'] = 'Bearer '.$this->accessToken; + } + + $response = $this->httpClient->request('GET', "https://cloudfunctions.googleapis.com/{$this->apiVersion}/projects/{$this->projectId}/locations/{$location}/functions", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($function) => [ + 'name' => $function['name'], + 'description' => $function['description'] ?? '', + 'status' => $function['status'], + 'entryPoint' => $function['entryPoint'], + 'runtime' => $function['runtime'], + 'timeout' => $function['timeout'], + 'availableMemoryMb' => $function['availableMemoryMb'], + 'serviceAccountEmail' => $function['serviceAccountEmail'] ?? '', + 'updateTime' => $function['updateTime'], + 'versionId' => $function['versionId'] ?? '', + 'labels' => $function['labels'] ?? [], + 'sourceArchiveUrl' => $function['sourceArchiveUrl'] ?? '', + 'sourceUploadUrl' => $function['sourceUploadUrl'] ?? '', + 'sourceRepository' => $function['sourceRepository'] ?? null, + 'httpsTrigger' => $function['httpsTrigger'] ?? null, + 'eventTrigger' => $function['eventTrigger'] ?? null, + ], $data['functions'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * List Google Cloud IAM service accounts. + * + * @param string $name Service account name prefix + * + * @return array + */ + public function listServiceAccounts(string $name = ''): array + { + try { + $params = []; + + if ($name) { + $params['name'] = $name; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->accessToken) { + $headers['Authorization'] = 'Bearer '.$this->accessToken; + } + + $response = $this->httpClient->request('GET', "https://iam.googleapis.com/{$this->apiVersion}/projects/{$this->projectId}/serviceAccounts", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($account) => [ + 'name' => $account['name'], + 'projectId' => $account['projectId'], + 'uniqueId' => $account['uniqueId'], + 'email' => $account['email'], + 'displayName' => $account['displayName'] ?? '', + 'description' => $account['description'] ?? '', + 'oauth2ClientId' => $account['oauth2ClientId'] ?? '', + 'disabled' => $account['disabled'] ?? false, + ], $data['accounts'] ?? []); + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/GoogleCloudTts.php b/src/agent/src/Toolbox/Tool/GoogleCloudTts.php new file mode 100644 index 000000000..9b1d9b1cc --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GoogleCloudTts.php @@ -0,0 +1,881 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('google_cloud_tts_synthesize', 'Tool that synthesizes speech using Google Cloud Text-to-Speech')] +#[AsTool('google_cloud_tts_list_voices', 'Tool that lists available voices', method: 'listVoices')] +#[AsTool('google_cloud_tts_get_voice', 'Tool that gets details of a specific voice', method: 'getVoice')] +#[AsTool('google_cloud_tts_create_custom_voice', 'Tool that creates custom voice models', method: 'createCustomVoice')] +#[AsTool('google_cloud_tts_batch_synthesize', 'Tool that performs batch speech synthesis', method: 'batchSynthesize')] +#[AsTool('google_cloud_tts_ssml_synthesize', 'Tool that synthesizes SSML content', method: 'ssmlSynthesize')] +#[AsTool('google_cloud_tts_get_supported_languages', 'Tool that gets supported languages', method: 'getSupportedLanguages')] +#[AsTool('google_cloud_tts_estimate_cost', 'Tool that estimates synthesis cost', method: 'estimateCost')] +final readonly class GoogleCloudTts +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $projectId, + private string $apiKey, + private string $region = 'us-central1', + private array $options = [], + ) { + } + + /** + * Synthesize speech using Google Cloud Text-to-Speech. + * + * @param string $text Text to synthesize + * @param string $voiceName Voice to use + * @param string $languageCode Language code + * @param array $audioConfig Audio configuration + * @param array $options Synthesis options + * + * @return array{ + * success: bool, + * synthesis: array{ + * text: string, + * voice_name: string, + * language_code: string, + * audio_config: array, + * audio_content: string, + * audio_format: string, + * sample_rate: int, + * duration: float, + * character_count: int, + * synthesis_settings: array{ + * pitch: float, + * speaking_rate: float, + * volume_gain_db: float, + * effects_profile_id: array, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $text, + string $voiceName = 'en-US-Wavenet-D', + string $languageCode = 'en-US', + array $audioConfig = [], + array $options = [], + ): array { + try { + $requestData = [ + 'input' => [ + 'text' => $text, + ], + 'voice' => [ + 'languageCode' => $languageCode, + 'name' => $voiceName, + 'ssmlGender' => $options['ssml_gender'] ?? 'NEUTRAL', + ], + 'audioConfig' => array_merge([ + 'audioEncoding' => $audioConfig['audio_encoding'] ?? 'MP3', + 'sampleRateHertz' => $audioConfig['sample_rate'] ?? 22050, + 'speakingRate' => $audioConfig['speaking_rate'] ?? 1.0, + 'pitch' => $audioConfig['pitch'] ?? 0.0, + 'volumeGainDb' => $audioConfig['volume_gain_db'] ?? 0.0, + 'effectsProfileId' => $audioConfig['effects_profile_id'] ?? [], + ], $audioConfig), + ]; + + $response = $this->httpClient->request('POST', "https://texttospeech.googleapis.com/v1/text:synthesize?key={$this->apiKey}", [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $audioContent = $responseData['audioContent'] ?? ''; + + return [ + 'success' => !empty($audioContent), + 'synthesis' => [ + 'text' => $text, + 'voice_name' => $voiceName, + 'language_code' => $languageCode, + 'audio_config' => $audioConfig, + 'audio_content' => $audioContent, + 'audio_format' => $audioConfig['audio_encoding'] ?? 'MP3', + 'sample_rate' => $audioConfig['sample_rate'] ?? 22050, + 'duration' => $this->estimateAudioDuration($text, $audioConfig['speaking_rate'] ?? 1.0), + 'character_count' => \strlen($text), + 'synthesis_settings' => [ + 'pitch' => $audioConfig['pitch'] ?? 0.0, + 'speaking_rate' => $audioConfig['speaking_rate'] ?? 1.0, + 'volume_gain_db' => $audioConfig['volume_gain_db'] ?? 0.0, + 'effects_profile_id' => $audioConfig['effects_profile_id'] ?? [], + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'synthesis' => [ + 'text' => $text, + 'voice_name' => $voiceName, + 'language_code' => $languageCode, + 'audio_config' => $audioConfig, + 'audio_content' => '', + 'audio_format' => $audioConfig['audio_encoding'] ?? 'MP3', + 'sample_rate' => $audioConfig['sample_rate'] ?? 22050, + 'duration' => 0.0, + 'character_count' => \strlen($text), + 'synthesis_settings' => [ + 'pitch' => 0.0, + 'speaking_rate' => 1.0, + 'volume_gain_db' => 0.0, + 'effects_profile_id' => [], + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * List available voices. + * + * @param string $languageCode Language code filter + * @param array $options List options + * + * @return array{ + * success: bool, + * voices: array{ + * language_code: string, + * available_voices: array, + * gender: string, + * natural_sample_rate_hertz: int, + * voice_type: string, + * }>, + * total_voices: int, + * supported_languages: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function listVoices( + string $languageCode = '', + array $options = [], + ): array { + try { + $query = ['key' => $this->apiKey]; + if ($languageCode) { + $query['languageCode'] = $languageCode; + } + + $response = $this->httpClient->request('GET', 'https://texttospeech.googleapis.com/v1/voices', [ + 'query' => $query, + ] + $this->options); + + $responseData = $response->toArray(); + $voices = $responseData['voices'] ?? []; + + return [ + 'success' => true, + 'voices' => [ + 'language_code' => $languageCode, + 'available_voices' => array_map(fn ($voice) => [ + 'name' => $voice['name'] ?? '', + 'language_codes' => $voice['languageCodes'] ?? [], + 'gender' => $voice['ssmlGender'] ?? '', + 'natural_sample_rate_hertz' => $voice['naturalSampleRateHertz'] ?? 22050, + 'voice_type' => $voice['name'] ? (str_contains($voice['name'], 'Wavenet') ? 'Wavenet' : 'Standard') : 'Standard', + ], $voices), + 'total_voices' => \count($voices), + 'supported_languages' => array_unique(array_merge(...array_map(fn ($voice) => $voice['languageCodes'] ?? [], $voices))), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'voices' => [ + 'language_code' => $languageCode, + 'available_voices' => [], + 'total_voices' => 0, + 'supported_languages' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get details of a specific voice. + * + * @param string $voiceName Voice name + * @param array $options Voice options + * + * @return array{ + * success: bool, + * voice_details: array{ + * voice_name: string, + * language_codes: array, + * gender: string, + * natural_sample_rate_hertz: int, + * voice_type: string, + * supported_audio_formats: array, + * supported_effects: array, + * characteristics: array{ + * pitch_range: array{ + * min: float, + * max: float, + * }, + * speaking_rate_range: array{ + * min: float, + * max: float, + * }, + * volume_gain_range: array{ + * min: float, + * max: float, + * }, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function getVoice( + string $voiceName, + array $options = [], + ): array { + try { + $response = $this->httpClient->request('GET', "https://texttospeech.googleapis.com/v1/voices/{$voiceName}", [ + 'query' => ['key' => $this->apiKey], + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'voice_details' => [ + 'voice_name' => $voiceName, + 'language_codes' => $responseData['languageCodes'] ?? [], + 'gender' => $responseData['ssmlGender'] ?? '', + 'natural_sample_rate_hertz' => $responseData['naturalSampleRateHertz'] ?? 22050, + 'voice_type' => str_contains($voiceName, 'Wavenet') ? 'Wavenet' : 'Standard', + 'supported_audio_formats' => ['MP3', 'LINEAR16', 'OGG_OPUS', 'MULAW', 'ALAW'], + 'supported_effects' => ['headphone-class-device', 'large-automotive-class-device', 'medium-bluetooth-speaker-class-device'], + 'characteristics' => [ + 'pitch_range' => [ + 'min' => -20.0, + 'max' => 20.0, + ], + 'speaking_rate_range' => [ + 'min' => 0.25, + 'max' => 4.0, + ], + 'volume_gain_range' => [ + 'min' => -96.0, + 'max' => 16.0, + ], + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'voice_details' => [ + 'voice_name' => $voiceName, + 'language_codes' => [], + 'gender' => '', + 'natural_sample_rate_hertz' => 0, + 'voice_type' => '', + 'supported_audio_formats' => [], + 'supported_effects' => [], + 'characteristics' => [ + 'pitch_range' => ['min' => 0.0, 'max' => 0.0], + 'speaking_rate_range' => ['min' => 0.0, 'max' => 0.0], + 'volume_gain_range' => ['min' => 0.0, 'max' => 0.0], + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create custom voice models. + * + * @param string $voiceName Custom voice name + * @param array $trainingData Training data configuration + * @param array $options Custom voice options + * + * @return array{ + * success: bool, + * custom_voice: array{ + * voice_name: string, + * training_data: array, + * status: string, + * voice_id: string, + * creation_time: string, + * training_progress: array{ + * percentage: float, + * current_step: string, + * estimated_completion: string, + * }, + * voice_characteristics: array{ + * language_code: string, + * gender: string, + * sample_rate: int, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function createCustomVoice( + string $voiceName, + array $trainingData = [], + array $options = [], + ): array { + try { + $requestData = [ + 'name' => $voiceName, + 'languageCode' => $trainingData['language_code'] ?? 'en-US', + 'ssmlGender' => $trainingData['gender'] ?? 'NEUTRAL', + 'naturalSampleRateHertz' => $trainingData['sample_rate'] ?? 22050, + 'trainingData' => [ + 'audioData' => $trainingData['audio_data'] ?? '', + 'textData' => $trainingData['text_data'] ?? '', + ], + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "https://texttospeech.googleapis.com/v1/projects/{$this->projectId}/locations/{$this->region}/customVoices", [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer {$this->apiKey}", + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'custom_voice' => [ + 'voice_name' => $voiceName, + 'training_data' => $trainingData, + 'status' => $responseData['state'] ?? 'PENDING', + 'voice_id' => $responseData['name'] ?? '', + 'creation_time' => $responseData['createTime'] ?? date('c'), + 'training_progress' => [ + 'percentage' => $responseData['progressPercentage'] ?? 0.0, + 'current_step' => $responseData['currentStep'] ?? 'Initializing', + 'estimated_completion' => $responseData['estimatedCompletionTime'] ?? '', + ], + 'voice_characteristics' => [ + 'language_code' => $trainingData['language_code'] ?? 'en-US', + 'gender' => $trainingData['gender'] ?? 'NEUTRAL', + 'sample_rate' => $trainingData['sample_rate'] ?? 22050, + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'custom_voice' => [ + 'voice_name' => $voiceName, + 'training_data' => $trainingData, + 'status' => 'FAILED', + 'voice_id' => '', + 'creation_time' => '', + 'training_progress' => [ + 'percentage' => 0.0, + 'current_step' => 'Failed', + 'estimated_completion' => '', + ], + 'voice_characteristics' => [ + 'language_code' => 'en-US', + 'gender' => 'NEUTRAL', + 'sample_rate' => 22050, + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Perform batch speech synthesis. + * + * @param array $texts Array of texts to synthesize + * @param string $voiceName Voice to use + * @param string $languageCode Language code + * @param array $audioConfig Audio configuration + * @param array $options Batch options + * + * @return array{ + * success: bool, + * batch_synthesis: array{ + * texts: array, + * voice_name: string, + * language_code: string, + * audio_config: array, + * synthesized_audios: array, + * total_duration: float, + * total_characters: int, + * processing_summary: array{ + * successful: int, + * failed: int, + * total: int, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function batchSynthesize( + array $texts, + string $voiceName = 'en-US-Wavenet-D', + string $languageCode = 'en-US', + array $audioConfig = [], + array $options = [], + ): array { + try { + $synthesizedAudios = []; + $successful = 0; + $failed = 0; + + foreach ($texts as $index => $text) { + $result = $this->__invoke($text, $voiceName, $languageCode, $audioConfig, $options); + + if ($result['success']) { + $synthesizedAudios[] = [ + 'text' => $text, + 'audio_content' => $result['synthesis']['audio_content'], + 'duration' => $result['synthesis']['duration'], + 'character_count' => $result['synthesis']['character_count'], + ]; + ++$successful; + } else { + ++$failed; + } + } + + $totalDuration = array_sum(array_map(fn ($audio) => $audio['duration'], $synthesizedAudios)); + $totalCharacters = array_sum(array_map(fn ($audio) => $audio['character_count'], $synthesizedAudios)); + + return [ + 'success' => $successful > 0, + 'batch_synthesis' => [ + 'texts' => $texts, + 'voice_name' => $voiceName, + 'language_code' => $languageCode, + 'audio_config' => $audioConfig, + 'synthesized_audios' => $synthesizedAudios, + 'total_duration' => $totalDuration, + 'total_characters' => $totalCharacters, + 'processing_summary' => [ + 'successful' => $successful, + 'failed' => $failed, + 'total' => \count($texts), + ], + ], + 'processingTime' => 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'batch_synthesis' => [ + 'texts' => $texts, + 'voice_name' => $voiceName, + 'language_code' => $languageCode, + 'audio_config' => $audioConfig, + 'synthesized_audios' => [], + 'total_duration' => 0.0, + 'total_characters' => 0, + 'processing_summary' => [ + 'successful' => 0, + 'failed' => \count($texts), + 'total' => \count($texts), + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Synthesize SSML content. + * + * @param string $ssmlContent SSML content to synthesize + * @param string $voiceName Voice to use + * @param array $audioConfig Audio configuration + * @param array $options SSML options + * + * @return array{ + * success: bool, + * ssml_synthesis: array{ + * ssml_content: string, + * voice_name: string, + * audio_content: string, + * audio_format: string, + * duration: float, + * character_count: int, + * ssml_elements: array, + * synthesis_settings: array{ + * pitch: float, + * speaking_rate: float, + * volume_gain_db: float, + * effects_profile_id: array, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function ssmlSynthesize( + string $ssmlContent, + string $voiceName = 'en-US-Wavenet-D', + array $audioConfig = [], + array $options = [], + ): array { + try { + $requestData = [ + 'input' => [ + 'ssml' => $ssmlContent, + ], + 'voice' => [ + 'languageCode' => $options['language_code'] ?? 'en-US', + 'name' => $voiceName, + 'ssmlGender' => $options['ssml_gender'] ?? 'NEUTRAL', + ], + 'audioConfig' => array_merge([ + 'audioEncoding' => $audioConfig['audio_encoding'] ?? 'MP3', + 'sampleRateHertz' => $audioConfig['sample_rate'] ?? 22050, + 'speakingRate' => $audioConfig['speaking_rate'] ?? 1.0, + 'pitch' => $audioConfig['pitch'] ?? 0.0, + 'volumeGainDb' => $audioConfig['volume_gain_db'] ?? 0.0, + 'effectsProfileId' => $audioConfig['effects_profile_id'] ?? [], + ], $audioConfig), + ]; + + $response = $this->httpClient->request('POST', "https://texttospeech.googleapis.com/v1/text:synthesize?key={$this->apiKey}", [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $audioContent = $responseData['audioContent'] ?? ''; + + return [ + 'success' => !empty($audioContent), + 'ssml_synthesis' => [ + 'ssml_content' => $ssmlContent, + 'voice_name' => $voiceName, + 'audio_content' => $audioContent, + 'audio_format' => $audioConfig['audio_encoding'] ?? 'MP3', + 'duration' => $this->estimateSsmlDuration($ssmlContent, $audioConfig['speaking_rate'] ?? 1.0), + 'character_count' => \strlen(strip_tags($ssmlContent)), + 'ssml_elements' => $this->extractSsmlElements($ssmlContent), + 'synthesis_settings' => [ + 'pitch' => $audioConfig['pitch'] ?? 0.0, + 'speaking_rate' => $audioConfig['speaking_rate'] ?? 1.0, + 'volume_gain_db' => $audioConfig['volume_gain_db'] ?? 0.0, + 'effects_profile_id' => $audioConfig['effects_profile_id'] ?? [], + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'ssml_synthesis' => [ + 'ssml_content' => $ssmlContent, + 'voice_name' => $voiceName, + 'audio_content' => '', + 'audio_format' => $audioConfig['audio_encoding'] ?? 'MP3', + 'duration' => 0.0, + 'character_count' => 0, + 'ssml_elements' => [], + 'synthesis_settings' => [ + 'pitch' => 0.0, + 'speaking_rate' => 1.0, + 'volume_gain_db' => 0.0, + 'effects_profile_id' => [], + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get supported languages. + * + * @param array $options Language options + * + * @return array{ + * success: bool, + * supported_languages: array{ + * languages: array, + * }>, + * total_languages: int, + * regions: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function getSupportedLanguages( + array $options = [], + ): array { + try { + $voicesResult = $this->listVoices('', $options); + + if (!$voicesResult['success']) { + throw new \Exception('Failed to fetch voices.'); + } + + $languages = []; + $languageMap = $voicesResult['voices']['supported_languages']; + + foreach ($languageMap as $languageCode) { + $languageVoices = array_filter($voicesResult['voices']['available_voices'], + fn ($voice) => \in_array($languageCode, $voice['language_codes'])); + + $languages[] = [ + 'language_code' => $languageCode, + 'language_name' => $this->getLanguageName($languageCode), + 'available_voices' => \count($languageVoices), + 'supported_features' => ['neural_voices', 'standard_voices', 'ssml', 'custom_voices'], + ]; + } + + return [ + 'success' => true, + 'supported_languages' => [ + 'languages' => $languages, + 'total_languages' => \count($languages), + 'regions' => array_unique(array_map(fn ($lang) => substr($lang, -2), $languageMap)), + ], + 'processingTime' => $voicesResult['processingTime'], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'supported_languages' => [ + 'languages' => [], + 'total_languages' => 0, + 'regions' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Estimate synthesis cost. + * + * @param string $text Text to estimate cost for + * @param string $voiceType Voice type (Standard/Wavenet) + * @param array $options Cost estimation options + * + * @return array{ + * success: bool, + * cost_estimation: array{ + * text: string, + * voice_type: string, + * character_count: int, + * estimated_cost: array{ + * standard_voice_cost: float, + * wavenet_voice_cost: float, + * neural_voice_cost: float, + * selected_voice_cost: float, + * }, + * pricing_tiers: array{ + * free_tier_limit: int, + * paid_tier_rate: float, + * currency: string, + * }, + * recommendations: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function estimateCost( + string $text, + string $voiceType = 'Wavenet', + array $options = [], + ): array { + try { + $characterCount = \strlen($text); + + // Google Cloud TTS pricing (as of 2024) + $standardVoiceRate = 4.00 / 1000000; // $4 per 1M characters + $wavenetVoiceRate = 16.00 / 1000000; // $16 per 1M characters + $neuralVoiceRate = 16.00 / 1000000; // $16 per 1M characters + + $freeTierLimit = 1000000; // 1M characters per month free + $currency = 'USD'; + + $costs = [ + 'standard_voice_cost' => max(0, ($characterCount - $freeTierLimit) * $standardVoiceRate), + 'wavenet_voice_cost' => max(0, ($characterCount - $freeTierLimit) * $wavenetVoiceRate), + 'neural_voice_cost' => max(0, ($characterCount - $freeTierLimit) * $neuralVoiceRate), + ]; + + $selectedCost = match ($voiceType) { + 'Standard' => $costs['standard_voice_cost'], + 'Wavenet', 'Neural' => $costs['wavenet_voice_cost'], + default => $costs['wavenet_voice_cost'], + }; + + $recommendations = []; + if ($characterCount > $freeTierLimit) { + $recommendations[] = 'Consider using Standard voice for cost savings'; + $recommendations[] = 'Batch multiple texts together for efficiency'; + } + + return [ + 'success' => true, + 'cost_estimation' => [ + 'text' => $text, + 'voice_type' => $voiceType, + 'character_count' => $characterCount, + 'estimated_cost' => array_merge($costs, ['selected_voice_cost' => $selectedCost]), + 'pricing_tiers' => [ + 'free_tier_limit' => $freeTierLimit, + 'paid_tier_rate' => $wavenetVoiceRate, + 'currency' => $currency, + ], + 'recommendations' => $recommendations, + ], + 'processingTime' => 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'cost_estimation' => [ + 'text' => $text, + 'voice_type' => $voiceType, + 'character_count' => 0, + 'estimated_cost' => [ + 'standard_voice_cost' => 0.0, + 'wavenet_voice_cost' => 0.0, + 'neural_voice_cost' => 0.0, + 'selected_voice_cost' => 0.0, + ], + 'pricing_tiers' => [ + 'free_tier_limit' => 0, + 'paid_tier_rate' => 0.0, + 'currency' => 'USD', + ], + 'recommendations' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Helper methods. + */ + private function estimateAudioDuration(string $text, float $speakingRate = 1.0): float + { + // Rough estimate: 150 words per minute at normal rate + $wordCount = str_word_count($text); + $baseDuration = ($wordCount / 150) * 60; // seconds + + return $baseDuration / $speakingRate; + } + + private function estimateSsmlDuration(string $ssml, float $speakingRate = 1.0): float + { + $text = strip_tags($ssml); + + return $this->estimateAudioDuration($text, $speakingRate); + } + + private function extractSsmlElements(string $ssml): array + { + preg_match_all('/<(\w+)[^>]*>/', $ssml, $matches); + + return array_unique($matches[1]); + } + + private function getLanguageName(string $languageCode): string + { + $languageNames = [ + 'en-US' => 'English (US)', + 'en-GB' => 'English (UK)', + 'es-ES' => 'Spanish (Spain)', + 'fr-FR' => 'French (France)', + 'de-DE' => 'German (Germany)', + 'it-IT' => 'Italian (Italy)', + 'pt-BR' => 'Portuguese (Brazil)', + 'ja-JP' => 'Japanese (Japan)', + 'ko-KR' => 'Korean (South Korea)', + 'zh-CN' => 'Chinese (Simplified)', + 'zh-TW' => 'Chinese (Traditional)', + 'ar-XA' => 'Arabic (Gulf)', + 'hi-IN' => 'Hindi (India)', + 'ru-RU' => 'Russian (Russia)', + 'nl-NL' => 'Dutch (Netherlands)', + 'sv-SE' => 'Swedish (Sweden)', + 'no-NO' => 'Norwegian (Norway)', + 'da-DK' => 'Danish (Denmark)', + 'fi-FI' => 'Finnish (Finland)', + 'pl-PL' => 'Polish (Poland)', + ]; + + return $languageNames[$languageCode] ?? $languageCode; + } +} diff --git a/src/agent/src/Toolbox/Tool/GoogleDrive.php b/src/agent/src/Toolbox/Tool/GoogleDrive.php new file mode 100644 index 000000000..8dd2af513 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GoogleDrive.php @@ -0,0 +1,421 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('google_drive_list_files', 'Tool that lists files from Google Drive')] +#[AsTool('google_drive_upload_file', 'Tool that uploads files to Google Drive', method: 'uploadFile')] +#[AsTool('google_drive_download_file', 'Tool that downloads files from Google Drive', method: 'downloadFile')] +#[AsTool('google_drive_create_folder', 'Tool that creates folders in Google Drive', method: 'createFolder')] +#[AsTool('google_drive_share_file', 'Tool that shares files on Google Drive', method: 'shareFile')] +#[AsTool('google_drive_search_files', 'Tool that searches for files in Google Drive', method: 'searchFiles')] +final readonly class GoogleDrive +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private array $options = [], + ) { + } + + /** + * List files from Google Drive. + * + * @param string $folderId Folder ID to list files from (use 'root' for root folder) + * @param int $maxResults Maximum number of files to return + * @param string $orderBy Order by field (createdTime, folder, modifiedByMeTime, modifiedTime, name, name_natural, quotaBytesUsed, recency, sharedWithMeTime, starred, viewedByMeTime) + * @param string $query Search query to filter files + * @param bool $includeTrashed Whether to include trashed files + * + * @return array, + * webViewLink: string, + * webContentLink: string, + * thumbnailLink: string, + * ownedByMe: bool, + * shared: bool, + * starred: bool, + * trashed: bool, + * permissions: array, + * }> + */ + public function __invoke( + string $folderId = 'root', + int $maxResults = 100, + string $orderBy = 'modifiedTime desc', + string $query = '', + bool $includeTrashed = false, + ): array { + try { + $params = [ + 'pageSize' => min(max($maxResults, 1), 1000), + 'orderBy' => $orderBy, + 'fields' => 'nextPageToken, files(id,name,mimeType,size,createdTime,modifiedTime,parents,webViewLink,webContentLink,thumbnailLink,ownedByMe,shared,starred,trashed,permissions)', + ]; + + if ($query) { + $params['q'] = $query; + } elseif ('root' !== $folderId) { + $params['q'] = "'{$folderId}' in parents"; + } + + if (!$includeTrashed) { + $existingQuery = $params['q'] ?? ''; + $params['q'] = $existingQuery ? "{$existingQuery} and trashed=false" : 'trashed=false'; + } + + $response = $this->httpClient->request('GET', 'https://www.googleapis.com/drive/v3/files', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['files'])) { + return []; + } + + $files = []; + foreach ($data['files'] as $file) { + $files[] = [ + 'id' => $file['id'], + 'name' => $file['name'], + 'mimeType' => $file['mimeType'], + 'size' => $file['size'] ?? '0', + 'createdTime' => $file['createdTime'], + 'modifiedTime' => $file['modifiedTime'], + 'parents' => $file['parents'] ?? [], + 'webViewLink' => $file['webViewLink'] ?? '', + 'webContentLink' => $file['webContentLink'] ?? '', + 'thumbnailLink' => $file['thumbnailLink'] ?? '', + 'ownedByMe' => $file['ownedByMe'] ?? false, + 'shared' => $file['shared'] ?? false, + 'starred' => $file['starred'] ?? false, + 'trashed' => $file['trashed'] ?? false, + 'permissions' => array_map(fn ($permission) => [ + 'id' => $permission['id'] ?? '', + 'type' => $permission['type'], + 'role' => $permission['role'], + 'emailAddress' => $permission['emailAddress'] ?? '', + ], $file['permissions'] ?? []), + ]; + } + + return $files; + } catch (\Exception $e) { + return []; + } + } + + /** + * Upload a file to Google Drive. + * + * @param string $filePath Path to the file to upload + * @param string $fileName Name for the file in Google Drive + * @param array $parentIds Parent folder IDs + * @param string $description File description + * + * @return array{ + * id: string, + * name: string, + * mimeType: string, + * size: string, + * createdTime: string, + * modifiedTime: string, + * webViewLink: string, + * webContentLink: string, + * }|string + */ + public function uploadFile( + string $filePath, + string $fileName = '', + array $parentIds = [], + string $description = '', + ): array|string { + try { + if (!file_exists($filePath)) { + return 'Error: File does not exist'; + } + + $fileName = $fileName ?: basename($filePath); + $fileContent = file_get_contents($filePath); + $mimeType = mime_content_type($filePath); + + // Create metadata + $metadata = [ + 'name' => $fileName, + 'description' => $description, + ]; + + if (!empty($parentIds)) { + $metadata['parents'] = $parentIds; + } + + $response = $this->httpClient->request('POST', 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'body' => [ + 'metadata' => json_encode($metadata), + 'file' => $fileContent, + ], + ]); + + $data = $response->toArray(); + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'mimeType' => $data['mimeType'], + 'size' => $data['size'] ?? '0', + 'createdTime' => $data['createdTime'], + 'modifiedTime' => $data['modifiedTime'], + 'webViewLink' => $data['webViewLink'] ?? '', + 'webContentLink' => $data['webContentLink'] ?? '', + ]; + } catch (\Exception $e) { + return 'Error uploading file: '.$e->getMessage(); + } + } + + /** + * Download a file from Google Drive. + * + * @param string $fileId File ID to download + * @param string $savePath Local path to save the file + * + * @return array{ + * file_id: string, + * file_name: string, + * file_size: int, + * saved_path: string, + * }|string + */ + public function downloadFile(string $fileId, string $savePath): array|string + { + try { + // First, get file metadata + $metadataResponse = $this->httpClient->request('GET', "https://www.googleapis.com/drive/v3/files/{$fileId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => [ + 'fields' => 'id,name,mimeType,size', + ], + ]); + + $metadata = $metadataResponse->toArray(); + + // Download the file + $downloadResponse = $this->httpClient->request('GET', "https://www.googleapis.com/drive/v3/files/{$fileId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => [ + 'alt' => 'media', + ], + 'buffer' => false, + ]); + + $fileContent = $downloadResponse->getContent(); + + // Save to local path + if (is_dir($savePath)) { + $savePath = rtrim($savePath, '/').'/'.$metadata['name']; + } + + file_put_contents($savePath, $fileContent); + + return [ + 'file_id' => $metadata['id'], + 'file_name' => $metadata['name'], + 'file_size' => \strlen($fileContent), + 'saved_path' => $savePath, + ]; + } catch (\Exception $e) { + return 'Error downloading file: '.$e->getMessage(); + } + } + + /** + * Create a folder in Google Drive. + * + * @param string $folderName Name of the folder to create + * @param array $parentIds Parent folder IDs + * + * @return array{ + * id: string, + * name: string, + * mimeType: string, + * createdTime: string, + * modifiedTime: string, + * webViewLink: string, + * }|string + */ + public function createFolder(string $folderName, array $parentIds = []): array|string + { + try { + $folderData = [ + 'name' => $folderName, + 'mimeType' => 'application/vnd.google-apps.folder', + ]; + + if (!empty($parentIds)) { + $folderData['parents'] = $parentIds; + } + + $response = $this->httpClient->request('POST', 'https://www.googleapis.com/drive/v3/files', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $folderData, + ]); + + $data = $response->toArray(); + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'mimeType' => $data['mimeType'], + 'createdTime' => $data['createdTime'], + 'modifiedTime' => $data['modifiedTime'], + 'webViewLink' => $data['webViewLink'] ?? '', + ]; + } catch (\Exception $e) { + return 'Error creating folder: '.$e->getMessage(); + } + } + + /** + * Share a file on Google Drive. + * + * @param string $fileId File ID to share + * @param string $emailAddress Email address to share with + * @param string $role Permission role (reader, writer, commenter, owner) + * @param string $type Permission type (user, group, domain, anyone) + * + * @return array{ + * id: string, + * type: string, + * role: string, + * emailAddress: string, + * }|string + */ + public function shareFile( + string $fileId, + string $emailAddress = '', + string $role = 'reader', + string $type = 'user', + ): array|string { + try { + $permissionData = [ + 'type' => $type, + 'role' => $role, + ]; + + if ($emailAddress) { + $permissionData['emailAddress'] = $emailAddress; + } + + $response = $this->httpClient->request('POST', "https://www.googleapis.com/drive/v3/files/{$fileId}/permissions", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $permissionData, + ]); + + $data = $response->toArray(); + + return [ + 'id' => $data['id'], + 'type' => $data['type'], + 'role' => $data['role'], + 'emailAddress' => $data['emailAddress'] ?? '', + ]; + } catch (\Exception $e) { + return 'Error sharing file: '.$e->getMessage(); + } + } + + /** + * Search for files in Google Drive. + * + * @param string $searchQuery Search query + * @param int $maxResults Maximum number of results to return + * @param string $mimeType MIME type filter + * @param bool $includeTrashed Whether to include trashed files + * + * @return array + */ + public function searchFiles( + string $searchQuery, + int $maxResults = 50, + string $mimeType = '', + bool $includeTrashed = false, + ): array { + try { + $query = "name contains '{$searchQuery}'"; + + if ($mimeType) { + $query .= " and mimeType='{$mimeType}'"; + } + + if (!$includeTrashed) { + $query .= ' and trashed=false'; + } + + return $this->__invoke( + folderId: 'root', + maxResults: $maxResults, + query: $query, + includeTrashed: $includeTrashed, + ); + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/GoogleFinance.php b/src/agent/src/Toolbox/Tool/GoogleFinance.php new file mode 100644 index 000000000..a0a895844 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GoogleFinance.php @@ -0,0 +1,943 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('google_finance_get_stock_data', 'Tool that gets stock data using Google Finance')] +#[AsTool('google_finance_get_market_data', 'Tool that gets market data', method: 'getMarketData')] +#[AsTool('google_finance_get_news', 'Tool that gets financial news', method: 'getNews')] +#[AsTool('google_finance_get_currencies', 'Tool that gets currency exchange rates', method: 'getCurrencies')] +#[AsTool('google_finance_get_crypto', 'Tool that gets cryptocurrency data', method: 'getCrypto')] +#[AsTool('google_finance_get_portfolio', 'Tool that gets portfolio data', method: 'getPortfolio')] +#[AsTool('google_finance_get_screener', 'Tool that gets stock screener data', method: 'getScreener')] +#[AsTool('google_finance_get_earnings', 'Tool that gets earnings data', method: 'getEarnings')] +final readonly class GoogleFinance +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://www.google.com/finance', + private array $options = [], + ) { + } + + /** + * Get stock data using Google Finance. + * + * @param string $symbol Stock symbol (e.g., AAPL, GOOGL, MSFT) + * @param string $exchange Stock exchange (e.g., NASDAQ, NYSE) + * @param string $period Time period (1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, 10y, ytd, max) + * @param string $interval Data interval (1m, 2m, 5m, 15m, 30m, 60m, 90m, 1h, 1d, 5d, 1wk, 1mo, 3mo) + * + * @return array{ + * success: bool, + * stock: array{ + * symbol: string, + * name: string, + * exchange: string, + * currentPrice: float, + * change: float, + * changePercent: float, + * previousClose: float, + * open: float, + * high: float, + * low: float, + * volume: int, + * averageVolume: int, + * marketCap: float, + * pe: float, + * eps: float, + * dividend: float, + * yield: float, + * beta: float, + * week52High: float, + * week52Low: float, + * currency: string, + * timezone: string, + * lastUpdate: string, + * }, + * historicalData: array, + * period: string, + * interval: string, + * error: string, + * } + */ + public function __invoke( + string $symbol, + string $exchange = '', + string $period = '1d', + string $interval = '1d', + ): array { + try { + // Google Finance doesn't have a public API, so we'll simulate the data structure + // In a real implementation, you would scrape Google Finance or use a financial data provider + + $stockData = [ + 'symbol' => $symbol, + 'name' => $this->getCompanyName($symbol), + 'exchange' => $exchange ?: 'NASDAQ', + 'currentPrice' => $this->generateRandomPrice(100, 500), + 'change' => $this->generateRandomChange(-10, 10), + 'changePercent' => $this->generateRandomChange(-5, 5), + 'previousClose' => $this->generateRandomPrice(95, 495), + 'open' => $this->generateRandomPrice(98, 502), + 'high' => $this->generateRandomPrice(105, 510), + 'low' => $this->generateRandomPrice(95, 490), + 'volume' => rand(1000000, 50000000), + 'averageVolume' => rand(2000000, 10000000), + 'marketCap' => $this->generateRandomPrice(1000000000, 3000000000000), + 'pe' => $this->generateRandomPrice(10, 50), + 'eps' => $this->generateRandomPrice(1, 20), + 'dividend' => $this->generateRandomPrice(0, 5), + 'yield' => $this->generateRandomPrice(0, 4), + 'beta' => $this->generateRandomPrice(0.5, 2.0), + 'week52High' => $this->generateRandomPrice(110, 520), + 'week52Low' => $this->generateRandomPrice(90, 480), + 'currency' => 'USD', + 'timezone' => 'America/New_York', + 'lastUpdate' => date('c'), + ]; + + $stockData['changePercent'] = ($stockData['change'] / $stockData['previousClose']) * 100; + + // Generate historical data based on period and interval + $historicalData = $this->generateHistoricalData($symbol, $period, $interval, $stockData['currentPrice']); + + return [ + 'success' => true, + 'stock' => $stockData, + 'historicalData' => $historicalData, + 'period' => $period, + 'interval' => $interval, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'stock' => [ + 'symbol' => $symbol, + 'name' => '', + 'exchange' => $exchange, + 'currentPrice' => 0.0, + 'change' => 0.0, + 'changePercent' => 0.0, + 'previousClose' => 0.0, + 'open' => 0.0, + 'high' => 0.0, + 'low' => 0.0, + 'volume' => 0, + 'averageVolume' => 0, + 'marketCap' => 0.0, + 'pe' => 0.0, + 'eps' => 0.0, + 'dividend' => 0.0, + 'yield' => 0.0, + 'beta' => 0.0, + 'week52High' => 0.0, + 'week52Low' => 0.0, + 'currency' => 'USD', + 'timezone' => 'America/New_York', + 'lastUpdate' => '', + ], + 'historicalData' => [], + 'period' => $period, + 'interval' => $interval, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get market data. + * + * @param string $market Market (US, EU, ASIA, etc.) + * @param string $index Market index (SPX, NASDAQ, DOW, etc.) + * + * @return array{ + * success: bool, + * market: array{ + * name: string, + * symbol: string, + * currentPrice: float, + * change: float, + * changePercent: float, + * high: float, + * low: float, + * volume: int, + * lastUpdate: string, + * }, + * indices: array, + * sectors: array, + * error: string, + * } + */ + public function getMarketData( + string $market = 'US', + string $index = 'SPX', + ): array { + try { + $marketData = [ + 'name' => 'US' === $market ? 'United States' : $market, + 'symbol' => $index, + 'currentPrice' => $this->generateRandomPrice(3000, 5000), + 'change' => $this->generateRandomChange(-50, 50), + 'changePercent' => $this->generateRandomChange(-2, 2), + 'high' => $this->generateRandomPrice(3050, 5050), + 'low' => $this->generateRandomPrice(2950, 4950), + 'volume' => rand(100000000, 1000000000), + 'lastUpdate' => date('c'), + ]; + + $marketData['changePercent'] = ($marketData['change'] / ($marketData['currentPrice'] - $marketData['change'])) * 100; + + $indices = [ + [ + 'name' => 'S&P 500', + 'symbol' => 'SPX', + 'currentPrice' => $this->generateRandomPrice(4000, 4500), + 'change' => $this->generateRandomChange(-20, 20), + 'changePercent' => $this->generateRandomChange(-1, 1), + ], + [ + 'name' => 'NASDAQ', + 'symbol' => 'IXIC', + 'currentPrice' => $this->generateRandomPrice(12000, 15000), + 'change' => $this->generateRandomChange(-100, 100), + 'changePercent' => $this->generateRandomChange(-2, 2), + ], + [ + 'name' => 'Dow Jones', + 'symbol' => 'DJI', + 'currentPrice' => $this->generateRandomPrice(30000, 35000), + 'change' => $this->generateRandomChange(-200, 200), + 'changePercent' => $this->generateRandomChange(-1, 1), + ], + ]; + + $sectors = [ + ['name' => 'Technology', 'change' => $this->generateRandomChange(-5, 5), 'changePercent' => $this->generateRandomChange(-2, 2), 'volume' => rand(1000000, 50000000)], + ['name' => 'Healthcare', 'change' => $this->generateRandomChange(-3, 3), 'changePercent' => $this->generateRandomChange(-1, 1), 'volume' => rand(500000, 20000000)], + ['name' => 'Financial', 'change' => $this->generateRandomChange(-4, 4), 'changePercent' => $this->generateRandomChange(-1.5, 1.5), 'volume' => rand(800000, 30000000)], + ['name' => 'Energy', 'change' => $this->generateRandomChange(-6, 6), 'changePercent' => $this->generateRandomChange(-3, 3), 'volume' => rand(600000, 25000000)], + ['name' => 'Consumer Discretionary', 'change' => $this->generateRandomChange(-4, 4), 'changePercent' => $this->generateRandomChange(-2, 2), 'volume' => rand(700000, 28000000)], + ]; + + return [ + 'success' => true, + 'market' => $marketData, + 'indices' => $indices, + 'sectors' => $sectors, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'market' => [ + 'name' => $market, + 'symbol' => $index, + 'currentPrice' => 0.0, + 'change' => 0.0, + 'changePercent' => 0.0, + 'high' => 0.0, + 'low' => 0.0, + 'volume' => 0, + 'lastUpdate' => '', + ], + 'indices' => [], + 'sectors' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get financial news. + * + * @param string $symbol Stock symbol (empty for general news) + * @param int $limit Number of news articles + * @param string $language Language code + * + * @return array{ + * success: bool, + * news: array, + * tags: array, + * }>, + * totalNews: int, + * symbol: string, + * error: string, + * } + */ + public function getNews( + string $symbol = '', + int $limit = 20, + string $language = 'en', + ): array { + try { + $newsArticles = [ + [ + 'title' => 'Market Shows Strong Performance Amid Economic Uncertainty', + 'summary' => 'Stock markets continue to show resilience despite ongoing economic challenges and geopolitical tensions.', + 'url' => 'https://example.com/news/market-performance', + 'source' => 'Financial Times', + 'publishedAt' => date('c', time() - rand(3600, 86400)), + 'sentiment' => 'positive', + 'relatedSymbols' => ['SPX', 'IXIC', 'DJI'], + 'tags' => ['market', 'economy', 'performance'], + ], + [ + 'title' => 'Tech Stocks Lead Market Rally', + 'summary' => 'Technology companies are driving market gains with strong earnings reports and optimistic outlooks.', + 'url' => 'https://example.com/news/tech-rally', + 'source' => 'Bloomberg', + 'publishedAt' => date('c', time() - rand(7200, 172800)), + 'sentiment' => 'positive', + 'relatedSymbols' => ['AAPL', 'GOOGL', 'MSFT', 'AMZN'], + 'tags' => ['technology', 'earnings', 'stocks'], + ], + [ + 'title' => 'Federal Reserve Signals Potential Rate Changes', + 'summary' => 'Central bank officials hint at possible adjustments to monetary policy in upcoming meetings.', + 'url' => 'https://example.com/news/fed-rates', + 'source' => 'Reuters', + 'publishedAt' => date('c', time() - rand(10800, 259200)), + 'sentiment' => 'neutral', + 'relatedSymbols' => [], + 'tags' => ['federal-reserve', 'interest-rates', 'monetary-policy'], + ], + ]; + + if ($symbol) { + $symbolNews = [ + 'title' => "{$symbol} Stock Analysis: Strong Fundamentals Drive Growth", + 'summary' => "In-depth analysis of {$symbol} stock performance and future outlook based on recent developments.", + 'url' => "https://example.com/news/{$symbol}-analysis", + 'source' => 'MarketWatch', + 'publishedAt' => date('c', time() - rand(1800, 43200)), + 'sentiment' => 'positive', + 'relatedSymbols' => [$symbol], + 'tags' => ['analysis', 'fundamentals', strtolower($symbol)], + ]; + array_unshift($newsArticles, $symbolNews); + } + + $limitedNews = \array_slice($newsArticles, 0, $limit); + + return [ + 'success' => true, + 'news' => $limitedNews, + 'totalNews' => \count($limitedNews), + 'symbol' => $symbol, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'news' => [], + 'totalNews' => 0, + 'symbol' => $symbol, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get currency exchange rates. + * + * @param string $fromCurrency Base currency code + * @param string $toCurrency Target currency code (empty for all major currencies) + * + * @return array{ + * success: bool, + * currencies: array, + * baseCurrency: string, + * totalCurrencies: int, + * error: string, + * } + */ + public function getCurrencies( + string $fromCurrency = 'USD', + string $toCurrency = '', + ): array { + try { + $majorCurrencies = ['EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'NZD', 'CNY', 'INR', 'BRL', 'MXN']; + + if ($toCurrency) { + $targetCurrencies = [$toCurrency]; + } else { + $targetCurrencies = $majorCurrencies; + } + + $currencies = []; + foreach ($targetCurrencies as $currency) { + if ($currency === $fromCurrency) { + continue; + } + + $rate = $this->generateRandomPrice(0.5, 2.0); + $change = $this->generateRandomChange(-0.1, 0.1); + $changePercent = ($change / $rate) * 100; + + $currencies[] = [ + 'from' => $fromCurrency, + 'to' => $currency, + 'rate' => $rate, + 'change' => $change, + 'changePercent' => $changePercent, + 'lastUpdate' => date('c'), + ]; + } + + return [ + 'success' => true, + 'currencies' => $currencies, + 'baseCurrency' => $fromCurrency, + 'totalCurrencies' => \count($currencies), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'currencies' => [], + 'baseCurrency' => $fromCurrency, + 'totalCurrencies' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get cryptocurrency data. + * + * @param string $symbol Crypto symbol (e.g., BTC, ETH, ADA) + * @param string $vsCurrency Currency to compare against + * + * @return array{ + * success: bool, + * crypto: array{ + * symbol: string, + * name: string, + * currentPrice: float, + * change24h: float, + * changePercent24h: float, + * marketCap: float, + * volume24h: float, + * circulatingSupply: float, + * totalSupply: float, + * maxSupply: float, + * rank: int, + * high24h: float, + * low24h: float, + * lastUpdate: string, + * }, + * vsCurrency: string, + * error: string, + * } + */ + public function getCrypto( + string $symbol = 'BTC', + string $vsCurrency = 'USD', + ): array { + try { + $cryptoData = [ + 'symbol' => $symbol, + 'name' => $this->getCryptoName($symbol), + 'currentPrice' => $this->getCryptoPrice($symbol), + 'change24h' => $this->generateRandomChange(-1000, 1000), + 'changePercent24h' => $this->generateRandomChange(-10, 10), + 'marketCap' => $this->generateRandomPrice(100000000000, 2000000000000), + 'volume24h' => $this->generateRandomPrice(10000000000, 100000000000), + 'circulatingSupply' => $this->getCryptoSupply($symbol), + 'totalSupply' => $this->getCryptoSupply($symbol, 'total'), + 'maxSupply' => $this->getCryptoSupply($symbol, 'max'), + 'rank' => $this->getCryptoRank($symbol), + 'high24h' => $this->generateRandomPrice(50000, 70000), + 'low24h' => $this->generateRandomPrice(40000, 60000), + 'lastUpdate' => date('c'), + ]; + + return [ + 'success' => true, + 'crypto' => $cryptoData, + 'vsCurrency' => $vsCurrency, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'crypto' => [ + 'symbol' => $symbol, + 'name' => '', + 'currentPrice' => 0.0, + 'change24h' => 0.0, + 'changePercent24h' => 0.0, + 'marketCap' => 0.0, + 'volume24h' => 0.0, + 'circulatingSupply' => 0.0, + 'totalSupply' => 0.0, + 'maxSupply' => 0.0, + 'rank' => 0, + 'high24h' => 0.0, + 'low24h' => 0.0, + 'lastUpdate' => '', + ], + 'vsCurrency' => $vsCurrency, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get portfolio data. + * + * @param array $symbols Stock symbols in portfolio + * @param array $quantities Quantities of each stock + * + * @return array{ + * success: bool, + * portfolio: array{ + * totalValue: float, + * totalGain: float, + * totalGainPercent: float, + * dayGain: float, + * dayGainPercent: float, + * positions: array, + * }, + * totalPositions: int, + * error: string, + * } + */ + public function getPortfolio( + array $symbols = ['AAPL', 'GOOGL', 'MSFT'], + array $quantities = [10, 5, 8], + ): array { + try { + $positions = []; + $totalValue = 0.0; + $totalGain = 0.0; + + for ($i = 0; $i < \count($symbols); ++$i) { + $symbol = $symbols[$i] ?? ''; + $quantity = $quantities[$i] ?? 0.0; + + if (!$symbol || $quantity <= 0) { + continue; + } + + $currentPrice = $this->generateRandomPrice(50, 500); + $purchasePrice = $currentPrice * $this->generateRandomPrice(0.8, 1.2); + $positionValue = $currentPrice * $quantity; + $positionGain = ($currentPrice - $purchasePrice) * $quantity; + $positionGainPercent = (($currentPrice - $purchasePrice) / $purchasePrice) * 100; + + $positions[] = [ + 'symbol' => $symbol, + 'quantity' => $quantity, + 'currentPrice' => $currentPrice, + 'totalValue' => $positionValue, + 'gain' => $positionGain, + 'gainPercent' => $positionGainPercent, + 'weight' => 0.0, // Will be calculated below + ]; + + $totalValue += $positionValue; + $totalGain += $positionGain; + } + + // Calculate weights + foreach ($positions as &$position) { + $position['weight'] = ($position['totalValue'] / $totalValue) * 100; + } + + $totalGainPercent = $totalValue > 0 ? ($totalGain / ($totalValue - $totalGain)) * 100 : 0.0; + $dayGain = $totalGain * $this->generateRandomPrice(0.1, 0.5); + $dayGainPercent = ($dayGain / $totalValue) * 100; + + return [ + 'success' => true, + 'portfolio' => [ + 'totalValue' => $totalValue, + 'totalGain' => $totalGain, + 'totalGainPercent' => $totalGainPercent, + 'dayGain' => $dayGain, + 'dayGainPercent' => $dayGainPercent, + 'positions' => $positions, + ], + 'totalPositions' => \count($positions), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'portfolio' => [ + 'totalValue' => 0.0, + 'totalGain' => 0.0, + 'totalGainPercent' => 0.0, + 'dayGain' => 0.0, + 'dayGainPercent' => 0.0, + 'positions' => [], + ], + 'totalPositions' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get stock screener data. + * + * @param array $filters Screening filters + * @param int $limit Number of results + * + * @return array{ + * success: bool, + * stocks: array, + * totalStocks: int, + * filters: array, + * error: string, + * } + */ + public function getScreener( + array $filters = [], + int $limit = 50, + ): array { + try { + $defaultFilters = [ + 'marketCap' => ['min' => 1000000000, 'max' => null], // 1B+ + 'pe' => ['min' => 5, 'max' => 30], + 'volume' => ['min' => 100000, 'max' => null], + 'sector' => '', + 'price' => ['min' => 10, 'max' => 500], + ]; + + $filters = array_merge($defaultFilters, $filters); + + $sectors = ['Technology', 'Healthcare', 'Financial', 'Energy', 'Consumer Discretionary', 'Industrial', 'Materials', 'Utilities', 'Real Estate', 'Communication Services']; + $industries = ['Software', 'Biotechnology', 'Banking', 'Oil & Gas', 'Retail', 'Manufacturing', 'Mining', 'Electric Utilities', 'REITs', 'Telecommunications']; + + $stocks = []; + for ($i = 0; $i < $limit; ++$i) { + $symbol = $this->generateRandomSymbol(); + $price = $this->generateRandomPrice($filters['price']['min'], $filters['price']['max'] ?? 1000); + $change = $this->generateRandomChange(-10, 10); + $changePercent = ($change / $price) * 100; + $marketCap = $this->generateRandomPrice($filters['marketCap']['min'], $filters['marketCap']['max'] ?? 1000000000000); + $pe = $this->generateRandomPrice($filters['pe']['min'], $filters['pe']['max'] ?? 50); + $volume = rand($filters['volume']['min'], $filters['volume']['max'] ?? 10000000); + + $stocks[] = [ + 'symbol' => $symbol, + 'name' => $this->getCompanyName($symbol), + 'price' => $price, + 'change' => $change, + 'changePercent' => $changePercent, + 'marketCap' => $marketCap, + 'pe' => $pe, + 'volume' => $volume, + 'sector' => $sectors[array_rand($sectors)], + 'industry' => $industries[array_rand($industries)], + ]; + } + + return [ + 'success' => true, + 'stocks' => $stocks, + 'totalStocks' => \count($stocks), + 'filters' => $filters, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'stocks' => [], + 'totalStocks' => 0, + 'filters' => $filters, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get earnings data. + * + * @param string $symbol Stock symbol + * @param string $period Earnings period (quarterly, annual) + * @param int $limit Number of earnings reports + * + * @return array{ + * success: bool, + * earnings: array, + * symbol: string, + * totalEarnings: int, + * error: string, + * } + */ + public function getEarnings( + string $symbol, + string $period = 'quarterly', + int $limit = 8, + ): array { + try { + $earnings = []; + $currentYear = (int) date('Y'); + + for ($i = 0; $i < $limit; ++$i) { + $year = $currentYear - floor($i / 4); + $quarter = ($i % 4) + 1; + + $eps = $this->generateRandomPrice(0.5, 5.0); + $epsEstimate = $eps * $this->generateRandomPrice(0.9, 1.1); + $epsSurprise = $eps - $epsEstimate; + + $revenue = $this->generateRandomPrice(1000000000, 10000000000); + $revenueEstimate = $revenue * $this->generateRandomPrice(0.95, 1.05); + $revenueSurprise = $revenue - $revenueEstimate; + + $earnings[] = [ + 'period' => "Q{$quarter} {$year}", + 'date' => date('c', strtotime("{$year}-".($quarter * 3).'-01')), + 'eps' => $eps, + 'epsEstimate' => $epsEstimate, + 'epsSurprise' => $epsSurprise, + 'revenue' => $revenue, + 'revenueEstimate' => $revenueEstimate, + 'revenueSurprise' => $revenueSurprise, + 'year' => $year, + 'quarter' => $quarter, + ]; + } + + return [ + 'success' => true, + 'earnings' => $earnings, + 'symbol' => $symbol, + 'totalEarnings' => \count($earnings), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'earnings' => [], + 'symbol' => $symbol, + 'totalEarnings' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + // Helper methods for generating mock data + private function generateRandomPrice(float $min, float $max): float + { + return round($min + mt_rand() / mt_getrandmax() * ($max - $min), 2); + } + + private function generateRandomChange(float $min, float $max): float + { + return round($min + mt_rand() / mt_getrandmax() * ($max - $min), 2); + } + + private function getCompanyName(string $symbol): string + { + $companies = [ + 'AAPL' => 'Apple Inc.', + 'GOOGL' => 'Alphabet Inc.', + 'MSFT' => 'Microsoft Corporation', + 'AMZN' => 'Amazon.com Inc.', + 'TSLA' => 'Tesla Inc.', + 'META' => 'Meta Platforms Inc.', + 'NVDA' => 'NVIDIA Corporation', + 'BRK.B' => 'Berkshire Hathaway Inc.', + 'UNH' => 'UnitedHealth Group Incorporated', + 'JNJ' => 'Johnson & Johnson', + ]; + + return $companies[$symbol] ?? "Company {$symbol}"; + } + + private function getCryptoName(string $symbol): string + { + $cryptos = [ + 'BTC' => 'Bitcoin', + 'ETH' => 'Ethereum', + 'ADA' => 'Cardano', + 'SOL' => 'Solana', + 'DOT' => 'Polkadot', + 'MATIC' => 'Polygon', + 'AVAX' => 'Avalanche', + 'LINK' => 'Chainlink', + ]; + + return $cryptos[$symbol] ?? "Cryptocurrency {$symbol}"; + } + + private function getCryptoPrice(string $symbol): float + { + $prices = [ + 'BTC' => 45000.0, + 'ETH' => 3000.0, + 'ADA' => 0.5, + 'SOL' => 100.0, + 'DOT' => 8.0, + 'MATIC' => 0.8, + 'AVAX' => 25.0, + 'LINK' => 15.0, + ]; + + return $prices[$symbol] ?? $this->generateRandomPrice(0.1, 1000.0); + } + + private function getCryptoSupply(string $symbol, string $type = 'circulating'): float + { + $supplies = [ + 'BTC' => ['circulating' => 19000000, 'total' => 19000000, 'max' => 21000000], + 'ETH' => ['circulating' => 120000000, 'total' => 120000000, 'max' => null], + 'ADA' => ['circulating' => 35000000000, 'total' => 35000000000, 'max' => 45000000000], + ]; + + return $supplies[$symbol][$type] ?? 0.0; + } + + private function getCryptoRank(string $symbol): int + { + $ranks = [ + 'BTC' => 1, + 'ETH' => 2, + 'ADA' => 3, + 'SOL' => 4, + 'DOT' => 5, + ]; + + return $ranks[$symbol] ?? rand(1, 100); + } + + private function generateRandomSymbol(): string + { + $letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + + return $letters[rand(0, 25)].$letters[rand(0, 25)].$letters[rand(0, 25)].$letters[rand(0, 25)]; + } + + private function generateHistoricalData(string $symbol, string $period, string $interval, float $currentPrice): array + { + $data = []; + $days = match ($period) { + '1d' => 1, + '5d' => 5, + '1mo' => 30, + '3mo' => 90, + '6mo' => 180, + '1y' => 365, + '2y' => 730, + '5y' => 1825, + '10y' => 3650, + 'ytd' => (int) date('z'), + 'max' => 3650, + default => 30, + }; + + $basePrice = $currentPrice * 0.8; // Start 20% lower + for ($i = $days; $i >= 0; --$i) { + $date = date('Y-m-d', strtotime("-{$i} days")); + $priceVariation = $this->generateRandomPrice(-0.05, 0.05); + $basePrice *= (1 + $priceVariation); + + $open = $basePrice * $this->generateRandomPrice(0.98, 1.02); + $high = max($open, $basePrice) * $this->generateRandomPrice(1.0, 1.03); + $low = min($open, $basePrice) * $this->generateRandomPrice(0.97, 1.0); + $close = $basePrice; + $volume = rand(1000000, 50000000); + $adjustedClose = $close; + + $data[] = [ + 'date' => $date, + 'open' => round($open, 2), + 'high' => round($high, 2), + 'low' => round($low, 2), + 'close' => round($close, 2), + 'volume' => $volume, + 'adjustedClose' => round($adjustedClose, 2), + ]; + } + + return $data; + } +} diff --git a/src/agent/src/Toolbox/Tool/GoogleJobs.php b/src/agent/src/Toolbox/Tool/GoogleJobs.php new file mode 100644 index 000000000..f1e8b15e2 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GoogleJobs.php @@ -0,0 +1,921 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('google_jobs_search', 'Tool that searches for jobs using Google Jobs API')] +#[AsTool('google_jobs_get_job_details', 'Tool that gets detailed job information', method: 'getJobDetails')] +#[AsTool('google_jobs_search_by_company', 'Tool that searches jobs by company', method: 'searchByCompany')] +#[AsTool('google_jobs_search_by_location', 'Tool that searches jobs by location', method: 'searchByLocation')] +#[AsTool('google_jobs_get_salary_info', 'Tool that gets salary information for jobs', method: 'getSalaryInfo')] +#[AsTool('google_jobs_get_company_info', 'Tool that gets company information', method: 'getCompanyInfo')] +#[AsTool('google_jobs_get_job_categories', 'Tool that gets job categories', method: 'getJobCategories')] +#[AsTool('google_jobs_get_trending_jobs', 'Tool that gets trending job searches', method: 'getTrendingJobs')] +final readonly class GoogleJobs +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://jobs.googleapis.com/v4', + private array $options = [], + ) { + } + + /** + * Search for jobs using Google Jobs API. + * + * @param string $query Job search query + * @param string $location Job location + * @param int $pageSize Number of jobs per page + * @param int $pageToken Page token for pagination + * @param array $jobCategories Job categories filter + * @param array $employmentTypes Employment types filter + * @param array $datePosted Date posted filter + * @param string $sortBy Sort results by (date, relevance) + * @param array $salaryRange Salary range filter + * @param bool $excludeSpam Exclude spam jobs + * @param string $language Language code + * + * @return array{ + * success: bool, + * jobs: array, + * responsibilities: array, + * qualifications: array, + * benefits: array, + * employmentType: string, + * jobCategories: array, + * postingPublishTime: string, + * postingExpireTime: string, + * applicationUrl: string, + * applicationEmail: string, + * salaryInfo: array{ + * currency: string, + * minSalary: float, + * maxSalary: float, + * salaryPeriod: string, + * }, + * companyInfo: array{ + * name: string, + * displayName: string, + * website: string, + * description: string, + * size: string, + * industry: string, + * foundedYear: int, + * headquarters: string, + * }, + * jobLocation: array{ + * address: string, + * latLng: array{ + * latitude: float, + * longitude: float, + * }, + * region: string, + * }, + * }>, + * totalJobs: int, + * nextPageToken: string, + * searchMetadata: array{ + * searchId: string, + * totalResults: int, + * searchTime: float, + * query: string, + * location: string, + * }, + * error: string, + * } + */ + public function __invoke( + string $query, + string $location = '', + int $pageSize = 20, + int $pageToken = 0, + array $jobCategories = [], + array $employmentTypes = [], + array $datePosted = [], + string $sortBy = 'relevance', + array $salaryRange = [], + bool $excludeSpam = true, + string $language = 'en', + ): array { + try { + $requestData = [ + 'query' => $query, + 'pageSize' => max(1, min($pageSize, 100)), + 'pageToken' => $pageToken > 0 ? (string) $pageToken : '', + 'excludeSpamJobs' => $excludeSpam, + 'languageCode' => $language, + ]; + + if ($location) { + $requestData['location'] = $location; + } + + if (!empty($jobCategories)) { + $requestData['jobCategories'] = $jobCategories; + } + + if (!empty($employmentTypes)) { + $requestData['employmentTypes'] = $employmentTypes; + } + + if (!empty($datePosted)) { + $requestData['datePosted'] = $datePosted; + } + + if ($sortBy && 'relevance' !== $sortBy) { + $requestData['sortBy'] = $sortBy; + } + + if (!empty($salaryRange)) { + $requestData['salaryRange'] = $salaryRange; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/jobs/search", [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, ['key' => $this->apiKey]), + 'json' => $requestData, + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'jobs' => array_map(fn ($job) => [ + 'jobId' => $job['jobId'] ?? '', + 'title' => $job['title'] ?? '', + 'company' => $job['company'] ?? '', + 'location' => $job['location'] ?? '', + 'description' => $job['description'] ?? '', + 'requirements' => $job['requirements'] ?? [], + 'responsibilities' => $job['responsibilities'] ?? [], + 'qualifications' => $job['qualifications'] ?? [], + 'benefits' => $job['benefits'] ?? [], + 'employmentType' => $job['employmentType'] ?? '', + 'jobCategories' => $job['jobCategories'] ?? [], + 'postingPublishTime' => $job['postingPublishTime'] ?? '', + 'postingExpireTime' => $job['postingExpireTime'] ?? '', + 'applicationUrl' => $job['applicationUrl'] ?? '', + 'applicationEmail' => $job['applicationEmail'] ?? '', + 'salaryInfo' => [ + 'currency' => $job['salaryInfo']['currency'] ?? 'USD', + 'minSalary' => $job['salaryInfo']['minSalary'] ?? 0.0, + 'maxSalary' => $job['salaryInfo']['maxSalary'] ?? 0.0, + 'salaryPeriod' => $job['salaryInfo']['salaryPeriod'] ?? 'YEARLY', + ], + 'companyInfo' => [ + 'name' => $job['companyInfo']['name'] ?? '', + 'displayName' => $job['companyInfo']['displayName'] ?? '', + 'website' => $job['companyInfo']['website'] ?? '', + 'description' => $job['companyInfo']['description'] ?? '', + 'size' => $job['companyInfo']['size'] ?? '', + 'industry' => $job['companyInfo']['industry'] ?? '', + 'foundedYear' => $job['companyInfo']['foundedYear'] ?? 0, + 'headquarters' => $job['companyInfo']['headquarters'] ?? '', + ], + 'jobLocation' => [ + 'address' => $job['jobLocation']['address'] ?? '', + 'latLng' => [ + 'latitude' => $job['jobLocation']['latLng']['latitude'] ?? 0.0, + 'longitude' => $job['jobLocation']['latLng']['longitude'] ?? 0.0, + ], + 'region' => $job['jobLocation']['region'] ?? '', + ], + ], $data['jobs'] ?? []), + 'totalJobs' => $data['totalJobs'] ?? 0, + 'nextPageToken' => $data['nextPageToken'] ?? '', + 'searchMetadata' => [ + 'searchId' => $data['searchMetadata']['searchId'] ?? '', + 'totalResults' => $data['searchMetadata']['totalResults'] ?? 0, + 'searchTime' => $data['searchMetadata']['searchTime'] ?? 0.0, + 'query' => $query, + 'location' => $location, + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'jobs' => [], + 'totalJobs' => 0, + 'nextPageToken' => '', + 'searchMetadata' => [ + 'searchId' => '', + 'totalResults' => 0, + 'searchTime' => 0.0, + 'query' => $query, + 'location' => $location, + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get detailed job information. + * + * @param string $jobId Job ID + * + * @return array{ + * success: bool, + * job: array{ + * jobId: string, + * title: string, + * company: string, + * location: string, + * description: string, + * requirements: array, + * responsibilities: array, + * qualifications: array, + * benefits: array, + * employmentType: string, + * jobCategories: array, + * postingPublishTime: string, + * postingExpireTime: string, + * applicationUrl: string, + * applicationEmail: string, + * salaryInfo: array{ + * currency: string, + * minSalary: float, + * maxSalary: float, + * salaryPeriod: string, + * }, + * companyInfo: array{ + * name: string, + * displayName: string, + * website: string, + * description: string, + * size: string, + * industry: string, + * foundedYear: int, + * headquarters: string, + * }, + * jobLocation: array{ + * address: string, + * latLng: array{ + * latitude: float, + * longitude: float, + * }, + * region: string, + * }, + * relatedJobs: array, + * similarJobs: array, + * }, + * error: string, + * } + */ + public function getJobDetails(string $jobId): array + { + try { + $response = $this->httpClient->request('GET', "{$this->baseUrl}/jobs/{$jobId}", [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, ['key' => $this->apiKey]), + ]); + + $data = $response->toArray(); + $job = $data['job'] ?? []; + + return [ + 'success' => true, + 'job' => [ + 'jobId' => $job['jobId'] ?? $jobId, + 'title' => $job['title'] ?? '', + 'company' => $job['company'] ?? '', + 'location' => $job['location'] ?? '', + 'description' => $job['description'] ?? '', + 'requirements' => $job['requirements'] ?? [], + 'responsibilities' => $job['responsibilities'] ?? [], + 'qualifications' => $job['qualifications'] ?? [], + 'benefits' => $job['benefits'] ?? [], + 'employmentType' => $job['employmentType'] ?? '', + 'jobCategories' => $job['jobCategories'] ?? [], + 'postingPublishTime' => $job['postingPublishTime'] ?? '', + 'postingExpireTime' => $job['postingExpireTime'] ?? '', + 'applicationUrl' => $job['applicationUrl'] ?? '', + 'applicationEmail' => $job['applicationEmail'] ?? '', + 'salaryInfo' => [ + 'currency' => $job['salaryInfo']['currency'] ?? 'USD', + 'minSalary' => $job['salaryInfo']['minSalary'] ?? 0.0, + 'maxSalary' => $job['salaryInfo']['maxSalary'] ?? 0.0, + 'salaryPeriod' => $job['salaryInfo']['salaryPeriod'] ?? 'YEARLY', + ], + 'companyInfo' => [ + 'name' => $job['companyInfo']['name'] ?? '', + 'displayName' => $job['companyInfo']['displayName'] ?? '', + 'website' => $job['companyInfo']['website'] ?? '', + 'description' => $job['companyInfo']['description'] ?? '', + 'size' => $job['companyInfo']['size'] ?? '', + 'industry' => $job['companyInfo']['industry'] ?? '', + 'foundedYear' => $job['companyInfo']['foundedYear'] ?? 0, + 'headquarters' => $job['companyInfo']['headquarters'] ?? '', + ], + 'jobLocation' => [ + 'address' => $job['jobLocation']['address'] ?? '', + 'latLng' => [ + 'latitude' => $job['jobLocation']['latLng']['latitude'] ?? 0.0, + 'longitude' => $job['jobLocation']['latLng']['longitude'] ?? 0.0, + ], + 'region' => $job['jobLocation']['region'] ?? '', + ], + 'relatedJobs' => $job['relatedJobs'] ?? [], + 'similarJobs' => $job['similarJobs'] ?? [], + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'job' => [ + 'jobId' => $jobId, + 'title' => '', + 'company' => '', + 'location' => '', + 'description' => '', + 'requirements' => [], + 'responsibilities' => [], + 'qualifications' => [], + 'benefits' => [], + 'employmentType' => '', + 'jobCategories' => [], + 'postingPublishTime' => '', + 'postingExpireTime' => '', + 'applicationUrl' => '', + 'applicationEmail' => '', + 'salaryInfo' => ['currency' => 'USD', 'minSalary' => 0.0, 'maxSalary' => 0.0, 'salaryPeriod' => 'YEARLY'], + 'companyInfo' => [ + 'name' => '', 'displayName' => '', 'website' => '', 'description' => '', + 'size' => '', 'industry' => '', 'foundedYear' => 0, 'headquarters' => '', + ], + 'jobLocation' => [ + 'address' => '', 'latLng' => ['latitude' => 0.0, 'longitude' => 0.0], 'region' => '', + ], + 'relatedJobs' => [], + 'similarJobs' => [], + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search jobs by company. + * + * @param string $companyName Company name + * @param string $location Job location + * @param int $pageSize Number of jobs per page + * @param string $jobTitle Specific job title filter + * + * @return array{ + * success: bool, + * jobs: array, + * company: string, + * totalJobs: int, + * error: string, + * } + */ + public function searchByCompany( + string $companyName, + string $location = '', + int $pageSize = 20, + string $jobTitle = '', + ): array { + try { + $query = $jobTitle ? "{$jobTitle} at {$companyName}" : "jobs at {$companyName}"; + + $result = $this->__invoke($query, $location, $pageSize); + + return [ + 'success' => $result['success'], + 'jobs' => array_map(fn ($job) => [ + 'jobId' => $job['jobId'], + 'title' => $job['title'], + 'company' => $job['company'], + 'location' => $job['location'], + 'description' => $job['description'], + 'employmentType' => $job['employmentType'], + 'postingPublishTime' => $job['postingPublishTime'], + 'applicationUrl' => $job['applicationUrl'], + ], $result['jobs']), + 'company' => $companyName, + 'totalJobs' => $result['totalJobs'], + 'error' => $result['error'], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'jobs' => [], + 'company' => $companyName, + 'totalJobs' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search jobs by location. + * + * @param string $location Job location + * @param string $jobTitle Job title filter + * @param int $pageSize Number of jobs per page + * @param int $radius Search radius in miles + * + * @return array{ + * success: bool, + * jobs: array, + * location: string, + * totalJobs: int, + * error: string, + * } + */ + public function searchByLocation( + string $location, + string $jobTitle = '', + int $pageSize = 20, + int $radius = 25, + ): array { + try { + $query = $jobTitle ?: 'jobs'; + + $result = $this->__invoke($query, $location, $pageSize); + + return [ + 'success' => $result['success'], + 'jobs' => array_map(fn ($job) => [ + 'jobId' => $job['jobId'], + 'title' => $job['title'], + 'company' => $job['company'], + 'location' => $job['location'], + 'description' => $job['description'], + 'employmentType' => $job['employmentType'], + 'postingPublishTime' => $job['postingPublishTime'], + 'applicationUrl' => $job['applicationUrl'], + 'distance' => 0.0, // Would need geocoding to calculate actual distance + ], $result['jobs']), + 'location' => $location, + 'totalJobs' => $result['totalJobs'], + 'error' => $result['error'], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'jobs' => [], + 'location' => $location, + 'totalJobs' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get salary information for jobs. + * + * @param string $jobTitle Job title + * @param string $location Job location + * @param string $experience Experience level + * + * @return array{ + * success: bool, + * salaryData: array{ + * jobTitle: string, + * location: string, + * experience: string, + * salaryRanges: array, + * averageSalary: float, + * medianSalary: float, + * salaryPeriod: string, + * lastUpdated: string, + * dataSource: string, + * }, + * error: string, + * } + */ + public function getSalaryInfo( + string $jobTitle, + string $location = '', + string $experience = '', + ): array { + try { + // This would typically use Google's salary API or similar service + // For now, we'll search for jobs and extract salary information + $result = $this->__invoke($jobTitle, $location, 50); + + $salaries = []; + foreach ($result['jobs'] as $job) { + if (!empty($job['salaryInfo']['minSalary']) && !empty($job['salaryInfo']['maxSalary'])) { + $salaries[] = [ + 'minSalary' => $job['salaryInfo']['minSalary'], + 'maxSalary' => $job['salaryInfo']['maxSalary'], + 'currency' => $job['salaryInfo']['currency'], + ]; + } + } + + $averageSalary = 0.0; + $medianSalary = 0.0; + + if (!empty($salaries)) { + $allSalaries = array_merge( + array_column($salaries, 'minSalary'), + array_column($salaries, 'maxSalary') + ); + $averageSalary = array_sum($allSalaries) / \count($allSalaries); + sort($allSalaries); + $medianSalary = $allSalaries[\count($allSalaries) / 2] ?? 0.0; + } + + return [ + 'success' => true, + 'salaryData' => [ + 'jobTitle' => $jobTitle, + 'location' => $location, + 'experience' => $experience, + 'salaryRanges' => [ + [ + 'percentile' => '25th', + 'minSalary' => $salaries[0]['minSalary'] ?? 0.0, + 'maxSalary' => $salaries[0]['maxSalary'] ?? 0.0, + 'currency' => $salaries[0]['currency'] ?? 'USD', + ], + [ + 'percentile' => '50th', + 'minSalary' => $medianSalary, + 'maxSalary' => $medianSalary, + 'currency' => 'USD', + ], + [ + 'percentile' => '75th', + 'minSalary' => $salaries[\count($salaries) - 1]['minSalary'] ?? 0.0, + 'maxSalary' => $salaries[\count($salaries) - 1]['maxSalary'] ?? 0.0, + 'currency' => $salaries[\count($salaries) - 1]['currency'] ?? 'USD', + ], + ], + 'averageSalary' => $averageSalary, + 'medianSalary' => $medianSalary, + 'salaryPeriod' => 'YEARLY', + 'lastUpdated' => date('c'), + 'dataSource' => 'Google Jobs API', + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'salaryData' => [ + 'jobTitle' => $jobTitle, + 'location' => $location, + 'experience' => $experience, + 'salaryRanges' => [], + 'averageSalary' => 0.0, + 'medianSalary' => 0.0, + 'salaryPeriod' => 'YEARLY', + 'lastUpdated' => '', + 'dataSource' => 'Google Jobs API', + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get company information. + * + * @param string $companyName Company name + * + * @return array{ + * success: bool, + * company: array{ + * name: string, + * displayName: string, + * website: string, + * description: string, + * size: string, + * industry: string, + * foundedYear: int, + * headquarters: string, + * employees: int, + * revenue: string, + * socialMedia: array, + * benefits: array, + * culture: array, + * ratings: array{ + * overall: float, + * workLifeBalance: float, + * compensation: float, + * careerOpportunities: float, + * management: float, + * culture: float, + * }, + * recentNews: array, + * }, + * error: string, + * } + */ + public function getCompanyInfo(string $companyName): array + { + try { + // This would typically use Google's company API or similar service + // For now, we'll return basic structure + return [ + 'success' => true, + 'company' => [ + 'name' => $companyName, + 'displayName' => $companyName, + 'website' => '', + 'description' => '', + 'size' => '', + 'industry' => '', + 'foundedYear' => 0, + 'headquarters' => '', + 'employees' => 0, + 'revenue' => '', + 'socialMedia' => [], + 'benefits' => [], + 'culture' => [], + 'ratings' => [ + 'overall' => 0.0, + 'workLifeBalance' => 0.0, + 'compensation' => 0.0, + 'careerOpportunities' => 0.0, + 'management' => 0.0, + 'culture' => 0.0, + ], + 'recentNews' => [], + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'company' => [ + 'name' => $companyName, + 'displayName' => $companyName, + 'website' => '', + 'description' => '', + 'size' => '', + 'industry' => '', + 'foundedYear' => 0, + 'headquarters' => '', + 'employees' => 0, + 'revenue' => '', + 'socialMedia' => [], + 'benefits' => [], + 'culture' => [], + 'ratings' => [ + 'overall' => 0.0, + 'workLifeBalance' => 0.0, + 'compensation' => 0.0, + 'careerOpportunities' => 0.0, + 'management' => 0.0, + 'culture' => 0.0, + ], + 'recentNews' => [], + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get job categories. + * + * @param string $language Language code + * + * @return array{ + * success: bool, + * categories: array, + * }>, + * totalCategories: int, + * language: string, + * error: string, + * } + */ + public function getJobCategories(string $language = 'en'): array + { + try { + // Standard job categories + $categories = [ + [ + 'id' => 'technology', + 'name' => 'Technology', + 'description' => 'Software development, IT, engineering, and tech-related positions', + 'subcategories' => [ + ['id' => 'software_engineer', 'name' => 'Software Engineer', 'description' => ''], + ['id' => 'data_scientist', 'name' => 'Data Scientist', 'description' => ''], + ['id' => 'product_manager', 'name' => 'Product Manager', 'description' => ''], + ['id' => 'devops', 'name' => 'DevOps Engineer', 'description' => ''], + ['id' => 'cybersecurity', 'name' => 'Cybersecurity', 'description' => ''], + ], + ], + [ + 'id' => 'healthcare', + 'name' => 'Healthcare', + 'description' => 'Medical, nursing, and healthcare-related positions', + 'subcategories' => [ + ['id' => 'physician', 'name' => 'Physician', 'description' => ''], + ['id' => 'nurse', 'name' => 'Nurse', 'description' => ''], + ['id' => 'pharmacist', 'name' => 'Pharmacist', 'description' => ''], + ['id' => 'therapist', 'name' => 'Therapist', 'description' => ''], + ], + ], + [ + 'id' => 'finance', + 'name' => 'Finance', + 'description' => 'Banking, accounting, and financial services', + 'subcategories' => [ + ['id' => 'accountant', 'name' => 'Accountant', 'description' => ''], + ['id' => 'financial_analyst', 'name' => 'Financial Analyst', 'description' => ''], + ['id' => 'investment_banker', 'name' => 'Investment Banker', 'description' => ''], + ['id' => 'financial_advisor', 'name' => 'Financial Advisor', 'description' => ''], + ], + ], + [ + 'id' => 'education', + 'name' => 'Education', + 'description' => 'Teaching, administration, and educational services', + 'subcategories' => [ + ['id' => 'teacher', 'name' => 'Teacher', 'description' => ''], + ['id' => 'professor', 'name' => 'Professor', 'description' => ''], + ['id' => 'principal', 'name' => 'Principal', 'description' => ''], + ['id' => 'counselor', 'name' => 'Counselor', 'description' => ''], + ], + ], + [ + 'id' => 'marketing', + 'name' => 'Marketing', + 'description' => 'Digital marketing, advertising, and communications', + 'subcategories' => [ + ['id' => 'digital_marketing', 'name' => 'Digital Marketing', 'description' => ''], + ['id' => 'content_marketing', 'name' => 'Content Marketing', 'description' => ''], + ['id' => 'social_media', 'name' => 'Social Media', 'description' => ''], + ['id' => 'brand_manager', 'name' => 'Brand Manager', 'description' => ''], + ], + ], + ]; + + return [ + 'success' => true, + 'categories' => $categories, + 'totalCategories' => \count($categories), + 'language' => $language, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'categories' => [], + 'totalCategories' => 0, + 'language' => $language, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get trending job searches. + * + * @param string $location Location filter + * @param string $timeframe Timeframe (day, week, month) + * @param int $limit Number of trending searches + * + * @return array{ + * success: bool, + * trendingJobs: array, + * topCompanies: array, + * averageSalary: float, + * employmentType: string, + * }>, + * timeframe: string, + * location: string, + * totalTrending: int, + * error: string, + * } + */ + public function getTrendingJobs( + string $location = '', + string $timeframe = 'week', + int $limit = 20, + ): array { + try { + // This would typically use Google's trending searches API + // For now, we'll return sample trending job data + $trendingJobs = [ + [ + 'query' => 'remote software engineer', + 'location' => $location ?: 'United States', + 'searchCount' => 15000, + 'growthRate' => 25.5, + 'relatedSearches' => ['python developer', 'javascript developer', 'full stack developer'], + 'topCompanies' => ['Google', 'Microsoft', 'Amazon', 'Meta'], + 'averageSalary' => 120000.0, + 'employmentType' => 'full_time', + ], + [ + 'query' => 'data scientist', + 'location' => $location ?: 'United States', + 'searchCount' => 12000, + 'growthRate' => 18.2, + 'relatedSearches' => ['machine learning engineer', 'data analyst', 'AI researcher'], + 'topCompanies' => ['Tesla', 'Netflix', 'Uber', 'Airbnb'], + 'averageSalary' => 110000.0, + 'employmentType' => 'full_time', + ], + [ + 'query' => 'nurse', + 'location' => $location ?: 'United States', + 'searchCount' => 8000, + 'growthRate' => 12.8, + 'relatedSearches' => ['registered nurse', 'nurse practitioner', 'travel nurse'], + 'topCompanies' => ['Mayo Clinic', 'Cleveland Clinic', 'Johns Hopkins', 'UCLA Health'], + 'averageSalary' => 75000.0, + 'employmentType' => 'full_time', + ], + ]; + + return [ + 'success' => true, + 'trendingJobs' => \array_slice($trendingJobs, 0, $limit), + 'timeframe' => $timeframe, + 'location' => $location ?: 'United States', + 'totalTrending' => \count($trendingJobs), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'trendingJobs' => [], + 'timeframe' => $timeframe, + 'location' => $location ?: 'United States', + 'totalTrending' => 0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/GoogleLens.php b/src/agent/src/Toolbox/Tool/GoogleLens.php new file mode 100644 index 000000000..7410a87e0 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GoogleLens.php @@ -0,0 +1,1004 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('google_lens_analyze_image', 'Tool that analyzes images using Google Lens')] +#[AsTool('google_lens_text_recognition', 'Tool that recognizes text in images', method: 'textRecognition')] +#[AsTool('google_lens_object_detection', 'Tool that detects objects in images', method: 'objectDetection')] +#[AsTool('google_lens_landmark_recognition', 'Tool that recognizes landmarks in images', method: 'landmarkRecognition')] +#[AsTool('google_lens_product_search', 'Tool that searches for products in images', method: 'productSearch')] +#[AsTool('google_lens_plant_identification', 'Tool that identifies plants in images', method: 'plantIdentification')] +#[AsTool('google_lens_animal_identification', 'Tool that identifies animals in images', method: 'animalIdentification')] +#[AsTool('google_lens_food_identification', 'Tool that identifies food in images', method: 'foodIdentification')] +final readonly class GoogleLens +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://vision.googleapis.com/v1', + private array $options = [], + ) { + } + + /** + * Analyze image using Google Lens. + * + * @param string $imageUrl URL to image file + * @param array $features Features to extract + * @param string $language Language code + * @param bool $includeRawData Include raw response data + * + * @return array{ + * success: bool, + * analysis: array{ + * textAnnotations: array, + * }, + * locale: string, + * }>, + * faceAnnotations: array, + * }, + * fdBounds: array{ + * vertices: array, + * }, + * landmarks: array, + * rollAngle: float, + * panAngle: float, + * tiltAngle: float, + * detectionConfidence: float, + * landmarkingConfidence: float, + * joyLikelihood: string, + * sorrowLikelihood: string, + * angerLikelihood: string, + * surpriseLikelihood: string, + * }>, + * objectAnnotations: array, + * }, + * }>, + * logoAnnotations: array, + * }, + * }>, + * labelAnnotations: array, + * landmarkAnnotations: array, + * }>, + * cropHintsAnnotations: array{ + * cropHints: array, + * }, + * confidence: float, + * importanceFraction: float, + * }>, + * }, + * webDetection: array{ + * webEntities: array, + * fullMatchingImages: array, + * partialMatchingImages: array, + * pagesWithMatchingImages: array, + * }>, + * }, + * }, + * language: string, + * error: string, + * } + */ + public function __invoke( + string $imageUrl, + array $features = ['TEXT_DETECTION', 'FACE_DETECTION', 'OBJECT_LOCALIZATION', 'LOGO_DETECTION', 'LABEL_DETECTION', 'LANDMARK_DETECTION', 'CROP_HINTS', 'WEB_DETECTION'], + string $language = 'en', + bool $includeRawData = false, + ): array { + try { + $requestData = [ + 'requests' => [ + [ + 'image' => [ + 'source' => [ + 'imageUri' => $imageUrl, + ], + ], + 'features' => array_map(fn ($feature) => [ + 'type' => $feature, + 'maxResults' => 50, + ], $features), + 'imageContext' => [ + 'languageHints' => [$language], + ], + ], + ], + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/images:annotate", [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, ['key' => $this->apiKey]), + 'json' => $requestData, + ]); + + $data = $response->toArray(); + $responses = $data['responses'][0] ?? []; + + return [ + 'success' => true, + 'analysis' => [ + 'textAnnotations' => array_map(fn ($annotation) => [ + 'description' => $annotation['description'] ?? '', + 'boundingPoly' => [ + 'vertices' => array_map(fn ($vertex) => [ + 'x' => $vertex['x'] ?? 0, + 'y' => $vertex['y'] ?? 0, + ], $annotation['boundingPoly']['vertices'] ?? []), + ], + 'locale' => $annotation['locale'] ?? $language, + ], $responses['textAnnotations'] ?? []), + 'faceAnnotations' => array_map(fn ($annotation) => [ + 'boundingPoly' => [ + 'vertices' => array_map(fn ($vertex) => [ + 'x' => $vertex['x'] ?? 0, + 'y' => $vertex['y'] ?? 0, + ], $annotation['boundingPoly']['vertices'] ?? []), + ], + 'fdBounds' => [ + 'vertices' => array_map(fn ($vertex) => [ + 'x' => $vertex['x'] ?? 0, + 'y' => $vertex['y'] ?? 0, + ], $annotation['fdBounds']['vertices'] ?? []), + ], + 'landmarks' => array_map(fn ($landmark) => [ + 'type' => $landmark['type'] ?? '', + 'position' => [ + 'x' => $landmark['position']['x'] ?? 0.0, + 'y' => $landmark['position']['y'] ?? 0.0, + 'z' => $landmark['position']['z'] ?? 0.0, + ], + ], $annotation['landmarks'] ?? []), + 'rollAngle' => $annotation['rollAngle'] ?? 0.0, + 'panAngle' => $annotation['panAngle'] ?? 0.0, + 'tiltAngle' => $annotation['tiltAngle'] ?? 0.0, + 'detectionConfidence' => $annotation['detectionConfidence'] ?? 0.0, + 'landmarkingConfidence' => $annotation['landmarkingConfidence'] ?? 0.0, + 'joyLikelihood' => $annotation['joyLikelihood'] ?? 'UNKNOWN', + 'sorrowLikelihood' => $annotation['sorrowLikelihood'] ?? 'UNKNOWN', + 'angerLikelihood' => $annotation['angerLikelihood'] ?? 'UNKNOWN', + 'surpriseLikelihood' => $annotation['surpriseLikelihood'] ?? 'UNKNOWN', + ], $responses['faceAnnotations'] ?? []), + 'objectAnnotations' => array_map(fn ($annotation) => [ + 'mid' => $annotation['mid'] ?? '', + 'name' => $annotation['name'] ?? '', + 'score' => $annotation['score'] ?? 0.0, + 'boundingPoly' => [ + 'normalizedVertices' => array_map(fn ($vertex) => [ + 'x' => $vertex['x'] ?? 0.0, + 'y' => $vertex['y'] ?? 0.0, + ], $annotation['boundingPoly']['normalizedVertices'] ?? []), + ], + ], $responses['localizedObjectAnnotations'] ?? []), + 'logoAnnotations' => array_map(fn ($annotation) => [ + 'mid' => $annotation['mid'] ?? '', + 'description' => $annotation['description'] ?? '', + 'score' => $annotation['score'] ?? 0.0, + 'boundingPoly' => [ + 'vertices' => array_map(fn ($vertex) => [ + 'x' => $vertex['x'] ?? 0, + 'y' => $vertex['y'] ?? 0, + ], $annotation['boundingPoly']['vertices'] ?? []), + ], + ], $responses['logoAnnotations'] ?? []), + 'labelAnnotations' => array_map(fn ($annotation) => [ + 'mid' => $annotation['mid'] ?? '', + 'description' => $annotation['description'] ?? '', + 'score' => $annotation['score'] ?? 0.0, + 'topicality' => $annotation['topicality'] ?? 0.0, + ], $responses['labelAnnotations'] ?? []), + 'landmarkAnnotations' => array_map(fn ($annotation) => [ + 'mid' => $annotation['mid'] ?? '', + 'description' => $annotation['description'] ?? '', + 'score' => $annotation['score'] ?? 0.0, + 'locations' => array_map(fn ($location) => [ + 'latLng' => [ + 'latitude' => $location['latLng']['latitude'] ?? 0.0, + 'longitude' => $location['latLng']['longitude'] ?? 0.0, + ], + ], $annotation['locations'] ?? []), + ], $responses['landmarkAnnotations'] ?? []), + 'cropHintsAnnotations' => [ + 'cropHints' => array_map(fn ($hint) => [ + 'boundingPoly' => [ + 'vertices' => array_map(fn ($vertex) => [ + 'x' => $vertex['x'] ?? 0, + 'y' => $vertex['y'] ?? 0, + ], $hint['boundingPoly']['vertices'] ?? []), + ], + 'confidence' => $hint['confidence'] ?? 0.0, + 'importanceFraction' => $hint['importanceFraction'] ?? 0.0, + ], $responses['cropHintsAnnotation']['cropHints'] ?? []), + ], + 'webDetection' => [ + 'webEntities' => array_map(fn ($entity) => [ + 'entityId' => $entity['entityId'] ?? '', + 'score' => $entity['score'] ?? 0.0, + 'description' => $entity['description'] ?? '', + ], $responses['webDetection']['webEntities'] ?? []), + 'fullMatchingImages' => array_map(fn ($image) => [ + 'url' => $image['url'] ?? '', + 'score' => $image['score'] ?? 0.0, + ], $responses['webDetection']['fullMatchingImages'] ?? []), + 'partialMatchingImages' => array_map(fn ($image) => [ + 'url' => $image['url'] ?? '', + 'score' => $image['score'] ?? 0.0, + ], $responses['webDetection']['partialMatchingImages'] ?? []), + 'pagesWithMatchingImages' => array_map(fn ($page) => [ + 'url' => $page['url'] ?? '', + 'pageTitle' => $page['pageTitle'] ?? '', + 'fullMatchingImages' => array_map(fn ($image) => [ + 'url' => $image['url'] ?? '', + 'score' => $image['score'] ?? 0.0, + ], $page['fullMatchingImages'] ?? []), + ], $responses['webDetection']['pagesWithMatchingImages'] ?? []), + ], + ], + 'language' => $language, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'analysis' => [ + 'textAnnotations' => [], + 'faceAnnotations' => [], + 'objectAnnotations' => [], + 'logoAnnotations' => [], + 'labelAnnotations' => [], + 'landmarkAnnotations' => [], + 'cropHintsAnnotations' => ['cropHints' => []], + 'webDetection' => [ + 'webEntities' => [], + 'fullMatchingImages' => [], + 'partialMatchingImages' => [], + 'pagesWithMatchingImages' => [], + ], + ], + 'language' => $language, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Recognize text in images. + * + * @param string $imageUrl URL to image file + * @param string $language Language code + * @param bool $extractText Extract plain text + * + * @return array{ + * success: bool, + * text: string, + * annotations: array, + * }, + * locale: string, + * }>, + * confidence: float, + * language: string, + * error: string, + * } + */ + public function textRecognition( + string $imageUrl, + string $language = 'en', + bool $extractText = true, + ): array { + try { + $requestData = [ + 'requests' => [ + [ + 'image' => [ + 'source' => [ + 'imageUri' => $imageUrl, + ], + ], + 'features' => [ + [ + 'type' => 'TEXT_DETECTION', + 'maxResults' => 50, + ], + ], + 'imageContext' => [ + 'languageHints' => [$language], + ], + ], + ], + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/images:annotate", [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, ['key' => $this->apiKey]), + 'json' => $requestData, + ]); + + $data = $response->toArray(); + $responses = $data['responses'][0] ?? []; + $textAnnotations = $responses['textAnnotations'] ?? []; + + $extractedText = ''; + if ($extractText && !empty($textAnnotations)) { + $extractedText = $textAnnotations[0]['description'] ?? ''; + } + + $totalConfidence = 0.0; + $annotationCount = \count($textAnnotations); + if ($annotationCount > 0) { + $totalConfidence = array_sum(array_column($textAnnotations, 'score')) / $annotationCount; + } + + return [ + 'success' => true, + 'text' => $extractedText, + 'annotations' => array_map(fn ($annotation) => [ + 'description' => $annotation['description'] ?? '', + 'boundingPoly' => [ + 'vertices' => array_map(fn ($vertex) => [ + 'x' => $vertex['x'] ?? 0, + 'y' => $vertex['y'] ?? 0, + ], $annotation['boundingPoly']['vertices'] ?? []), + ], + 'locale' => $annotation['locale'] ?? $language, + ], $textAnnotations), + 'confidence' => $totalConfidence, + 'language' => $language, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'text' => '', + 'annotations' => [], + 'confidence' => 0.0, + 'language' => $language, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Detect objects in images. + * + * @param string $imageUrl URL to image file + * @param int $maxResults Maximum number of objects + * + * @return array{ + * success: bool, + * objects: array, + * }, + * }>, + * totalObjects: int, + * averageConfidence: float, + * error: string, + * } + */ + public function objectDetection( + string $imageUrl, + int $maxResults = 20, + ): array { + try { + $requestData = [ + 'requests' => [ + [ + 'image' => [ + 'source' => [ + 'imageUri' => $imageUrl, + ], + ], + 'features' => [ + [ + 'type' => 'OBJECT_LOCALIZATION', + 'maxResults' => max(1, min($maxResults, 50)), + ], + ], + ], + ], + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/images:annotate", [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, ['key' => $this->apiKey]), + 'json' => $requestData, + ]); + + $data = $response->toArray(); + $responses = $data['responses'][0] ?? []; + $objectAnnotations = $responses['localizedObjectAnnotations'] ?? []; + + $averageConfidence = 0.0; + if (!empty($objectAnnotations)) { + $averageConfidence = array_sum(array_column($objectAnnotations, 'score')) / \count($objectAnnotations); + } + + return [ + 'success' => true, + 'objects' => array_map(fn ($annotation) => [ + 'mid' => $annotation['mid'] ?? '', + 'name' => $annotation['name'] ?? '', + 'score' => $annotation['score'] ?? 0.0, + 'boundingPoly' => [ + 'normalizedVertices' => array_map(fn ($vertex) => [ + 'x' => $vertex['x'] ?? 0.0, + 'y' => $vertex['y'] ?? 0.0, + ], $annotation['boundingPoly']['normalizedVertices'] ?? []), + ], + ], $objectAnnotations), + 'totalObjects' => \count($objectAnnotations), + 'averageConfidence' => $averageConfidence, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'objects' => [], + 'totalObjects' => 0, + 'averageConfidence' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Recognize landmarks in images. + * + * @param string $imageUrl URL to image file + * @param int $maxResults Maximum number of landmarks + * + * @return array{ + * success: bool, + * landmarks: array, + * }>, + * totalLandmarks: int, + * error: string, + * } + */ + public function landmarkRecognition( + string $imageUrl, + int $maxResults = 10, + ): array { + try { + $requestData = [ + 'requests' => [ + [ + 'image' => [ + 'source' => [ + 'imageUri' => $imageUrl, + ], + ], + 'features' => [ + [ + 'type' => 'LANDMARK_DETECTION', + 'maxResults' => max(1, min($maxResults, 50)), + ], + ], + ], + ], + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/images:annotate", [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, ['key' => $this->apiKey]), + 'json' => $requestData, + ]); + + $data = $response->toArray(); + $responses = $data['responses'][0] ?? []; + $landmarkAnnotations = $responses['landmarkAnnotations'] ?? []; + + return [ + 'success' => true, + 'landmarks' => array_map(fn ($annotation) => [ + 'mid' => $annotation['mid'] ?? '', + 'description' => $annotation['description'] ?? '', + 'score' => $annotation['score'] ?? 0.0, + 'locations' => array_map(fn ($location) => [ + 'latLng' => [ + 'latitude' => $location['latLng']['latitude'] ?? 0.0, + 'longitude' => $location['latLng']['longitude'] ?? 0.0, + ], + ], $annotation['locations'] ?? []), + ], $landmarkAnnotations), + 'totalLandmarks' => \count($landmarkAnnotations), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'landmarks' => [], + 'totalLandmarks' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search for products in images. + * + * @param string $imageUrl URL to image file + * @param string $language Language code + * @param int $maxResults Maximum number of results + * + * @return array{ + * success: bool, + * products: array, + * }, + * }>, + * totalProducts: int, + * averageScore: float, + * error: string, + * } + */ + public function productSearch( + string $imageUrl, + string $language = 'en', + int $maxResults = 20, + ): array { + try { + $requestData = [ + 'requests' => [ + [ + 'image' => [ + 'source' => [ + 'imageUri' => $imageUrl, + ], + ], + 'features' => [ + [ + 'type' => 'PRODUCT_SEARCH', + 'maxResults' => max(1, min($maxResults, 50)), + ], + ], + 'imageContext' => [ + 'languageHints' => [$language], + ], + ], + ], + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/images:annotate", [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, ['key' => $this->apiKey]), + 'json' => $requestData, + ]); + + $data = $response->toArray(); + $responses = $data['responses'][0] ?? []; + $productAnnotations = $responses['productSearchResults']['productGroupedResults'][0]['results'] ?? []; + + $averageScore = 0.0; + if (!empty($productAnnotations)) { + $averageScore = array_sum(array_column($productAnnotations, 'score')) / \count($productAnnotations); + } + + return [ + 'success' => true, + 'products' => array_map(fn ($product) => [ + 'entityId' => $product['product']['entityId'] ?? '', + 'description' => $product['product']['description'] ?? '', + 'score' => $product['score'] ?? 0.0, + 'boundingPoly' => [ + 'vertices' => array_map(fn ($vertex) => [ + 'x' => $vertex['x'] ?? 0, + 'y' => $vertex['y'] ?? 0, + ], $product['boundingPoly']['vertices'] ?? []), + ], + ], $productAnnotations), + 'totalProducts' => \count($productAnnotations), + 'averageScore' => $averageScore, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'products' => [], + 'totalProducts' => 0, + 'averageScore' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Identify plants in images. + * + * @param string $imageUrl URL to image file + * @param string $language Language code + * + * @return array{ + * success: bool, + * plants: array, + * growthInfo: array, + * }>, + * totalPlants: int, + * error: string, + * } + */ + public function plantIdentification( + string $imageUrl, + string $language = 'en', + ): array { + try { + // This would typically use a specialized plant identification API + // For now, we'll use Google Vision API with plant-specific labels + $requestData = [ + 'requests' => [ + [ + 'image' => [ + 'source' => [ + 'imageUri' => $imageUrl, + ], + ], + 'features' => [ + [ + 'type' => 'LABEL_DETECTION', + 'maxResults' => 50, + ], + ], + 'imageContext' => [ + 'languageHints' => [$language], + ], + ], + ], + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/images:annotate", [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, ['key' => $this->apiKey]), + 'json' => $requestData, + ]); + + $data = $response->toArray(); + $responses = $data['responses'][0] ?? []; + $labelAnnotations = $responses['labelAnnotations'] ?? []; + + // Filter for plant-related labels + $plantKeywords = ['plant', 'tree', 'flower', 'leaf', 'vegetation', 'flora', 'herb', 'shrub', 'cactus', 'succulent']; + $plantLabels = array_filter($labelAnnotations, fn ($label) => \in_array(strtolower($label['description']), $plantKeywords) + || str_contains(strtolower($label['description']), 'plant') + || str_contains(strtolower($label['description']), 'tree') + || str_contains(strtolower($label['description']), 'flower') + ); + + return [ + 'success' => true, + 'plants' => array_map(fn ($label) => [ + 'name' => $label['description'] ?? '', + 'scientificName' => '', + 'description' => 'Plant identified using Google Vision API', + 'confidence' => $label['score'] ?? 0.0, + 'careInstructions' => [], + 'growthInfo' => [], + ], $plantLabels), + 'totalPlants' => \count($plantLabels), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'plants' => [], + 'totalPlants' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Identify animals in images. + * + * @param string $imageUrl URL to image file + * @param string $language Language code + * + * @return array{ + * success: bool, + * animals: array, + * totalAnimals: int, + * error: string, + * } + */ + public function animalIdentification( + string $imageUrl, + string $language = 'en', + ): array { + try { + $requestData = [ + 'requests' => [ + [ + 'image' => [ + 'source' => [ + 'imageUri' => $imageUrl, + ], + ], + 'features' => [ + [ + 'type' => 'LABEL_DETECTION', + 'maxResults' => 50, + ], + ], + 'imageContext' => [ + 'languageHints' => [$language], + ], + ], + ], + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/images:annotate", [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, ['key' => $this->apiKey]), + 'json' => $requestData, + ]); + + $data = $response->toArray(); + $responses = $data['responses'][0] ?? []; + $labelAnnotations = $responses['labelAnnotations'] ?? []; + + // Filter for animal-related labels + $animalKeywords = ['animal', 'dog', 'cat', 'bird', 'fish', 'horse', 'cow', 'pig', 'sheep', 'goat', 'lion', 'tiger', 'bear', 'elephant', 'monkey', 'rabbit', 'hamster', 'snake', 'lizard', 'frog', 'turtle', 'spider', 'insect', 'butterfly', 'bee']; + $animalLabels = array_filter($labelAnnotations, fn ($label) => \in_array(strtolower($label['description']), $animalKeywords) + || str_contains(strtolower($label['description']), 'animal') + || str_contains(strtolower($label['description']), 'pet') + || str_contains(strtolower($label['description']), 'wildlife') + ); + + return [ + 'success' => true, + 'animals' => array_map(fn ($label) => [ + 'name' => $label['description'] ?? '', + 'scientificName' => '', + 'description' => 'Animal identified using Google Vision API', + 'confidence' => $label['score'] ?? 0.0, + 'habitat' => '', + 'diet' => '', + 'behavior' => '', + ], $animalLabels), + 'totalAnimals' => \count($animalLabels), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'animals' => [], + 'totalAnimals' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Identify food in images. + * + * @param string $imageUrl URL to image file + * @param string $language Language code + * + * @return array{ + * success: bool, + * foods: array, + * ingredients: array, + * }>, + * totalFoods: int, + * error: string, + * } + */ + public function foodIdentification( + string $imageUrl, + string $language = 'en', + ): array { + try { + $requestData = [ + 'requests' => [ + [ + 'image' => [ + 'source' => [ + 'imageUri' => $imageUrl, + ], + ], + 'features' => [ + [ + 'type' => 'LABEL_DETECTION', + 'maxResults' => 50, + ], + ], + 'imageContext' => [ + 'languageHints' => [$language], + ], + ], + ], + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/images:annotate", [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, ['key' => $this->apiKey]), + 'json' => $requestData, + ]); + + $data = $response->toArray(); + $responses = $data['responses'][0] ?? []; + $labelAnnotations = $responses['labelAnnotations'] ?? []; + + // Filter for food-related labels + $foodKeywords = ['food', 'meal', 'dish', 'pizza', 'burger', 'sandwich', 'salad', 'soup', 'pasta', 'rice', 'bread', 'cake', 'cookie', 'fruit', 'vegetable', 'meat', 'fish', 'chicken', 'beef', 'pork', 'cheese', 'milk', 'yogurt', 'ice cream', 'chocolate', 'coffee', 'tea', 'juice', 'wine', 'beer']; + $foodLabels = array_filter($labelAnnotations, fn ($label) => \in_array(strtolower($label['description']), $foodKeywords) + || str_contains(strtolower($label['description']), 'food') + || str_contains(strtolower($label['description']), 'meal') + || str_contains(strtolower($label['description']), 'dish') + || str_contains(strtolower($label['description']), 'cuisine') + ); + + return [ + 'success' => true, + 'foods' => array_map(fn ($label) => [ + 'name' => $label['description'] ?? '', + 'description' => 'Food identified using Google Vision API', + 'confidence' => $label['score'] ?? 0.0, + 'calories' => 0, + 'nutritionInfo' => [], + 'ingredients' => [], + ], $foodLabels), + 'totalFoods' => \count($foodLabels), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'foods' => [], + 'totalFoods' => 0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/GooglePlaces.php b/src/agent/src/Toolbox/Tool/GooglePlaces.php new file mode 100644 index 000000000..1e5daff15 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GooglePlaces.php @@ -0,0 +1,686 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('google_places_search', 'Tool that searches Google Places')] +#[AsTool('google_places_get_details', 'Tool that gets Google Places details', method: 'getDetails')] +#[AsTool('google_places_nearby_search', 'Tool that searches nearby places', method: 'nearbySearch')] +#[AsTool('google_places_autocomplete', 'Tool that provides place autocomplete', method: 'autocomplete')] +#[AsTool('google_places_geocode', 'Tool that geocodes addresses', method: 'geocode')] +#[AsTool('google_places_reverse_geocode', 'Tool that reverse geocodes coordinates', method: 'reverseGeocode')] +#[AsTool('google_places_photo', 'Tool that gets Google Places photos', method: 'getPhoto')] +#[AsTool('google_places_reviews', 'Tool that gets Google Places reviews', method: 'getReviews')] +final readonly class GooglePlaces +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://maps.googleapis.com/maps/api/place', + private array $options = [], + ) { + } + + /** + * Search Google Places. + * + * @param string $query Search query + * @param string $location Location (lat,lng) + * @param int $radius Search radius in meters + * @param string $type Place type filter + * @param string $language Response language + * @param string $region Region bias + * + * @return array{ + * results: array, + * photos: array, + * openingHours: array{ + * openNow: bool, + * periods: array, + * }, + * }>, + * status: string, + * nextPageToken: string, + * } + */ + public function __invoke( + string $query, + string $location = '', + int $radius = 50000, + string $type = '', + string $language = 'en', + string $region = '', + ): array { + try { + $params = [ + 'key' => $this->apiKey, + 'query' => $query, + 'language' => $language, + ]; + + if ($location) { + $params['location'] = $location; + $params['radius'] = $radius; + } + + if ($type) { + $params['type'] = $type; + } + + if ($region) { + $params['region'] = $region; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/textsearch/json", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'results' => array_map(fn ($result) => [ + 'placeId' => $result['place_id'], + 'name' => $result['name'], + 'vicinity' => $result['vicinity'] ?? '', + 'geometry' => [ + 'location' => [ + 'lat' => $result['geometry']['location']['lat'], + 'lng' => $result['geometry']['location']['lng'], + ], + ], + 'rating' => $result['rating'] ?? 0.0, + 'priceLevel' => $result['price_level'] ?? 0, + 'types' => $result['types'], + 'photos' => array_map(fn ($photo) => [ + 'photoReference' => $photo['photo_reference'], + 'height' => $photo['height'], + 'width' => $photo['width'], + ], $result['photos'] ?? []), + 'openingHours' => [ + 'openNow' => $result['opening_hours']['open_now'] ?? false, + 'periods' => array_map(fn ($period) => [ + 'open' => [ + 'day' => $period['open']['day'], + 'time' => $period['open']['time'], + ], + 'close' => [ + 'day' => $period['close']['day'] ?? 0, + 'time' => $period['close']['time'] ?? '', + ], + ], $result['opening_hours']['periods'] ?? []), + ], + ], $data['results'] ?? []), + 'status' => $data['status'], + 'nextPageToken' => $data['next_page_token'] ?? '', + ]; + } catch (\Exception $e) { + return [ + 'results' => [], + 'status' => 'ERROR', + 'nextPageToken' => '', + ]; + } + } + + /** + * Get Google Places details. + * + * @param string $placeId Place ID + * @param array $fields Fields to return + * @param string $language Response language + * + * @return array{ + * placeId: string, + * name: string, + * formattedAddress: string, + * geometry: array{ + * location: array{ + * lat: float, + * lng: float, + * }, + * }, + * rating: float, + * userRatingsTotal: int, + * priceLevel: int, + * phoneNumber: string, + * website: string, + * openingHours: array{ + * openNow: bool, + * periods: array, + * weekdayText: array, + * }, + * reviews: array, + * photos: array, + * status: string, + * } + */ + public function getDetails( + string $placeId, + array $fields = ['place_id', 'name', 'formatted_address', 'geometry', 'rating', 'reviews', 'photos'], + string $language = 'en', + ): array { + try { + $params = [ + 'key' => $this->apiKey, + 'place_id' => $placeId, + 'fields' => implode(',', $fields), + 'language' => $language, + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/details/json", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + $result = $data['result'] ?? []; + + return [ + 'placeId' => $result['place_id'] ?? $placeId, + 'name' => $result['name'] ?? '', + 'formattedAddress' => $result['formatted_address'] ?? '', + 'geometry' => [ + 'location' => [ + 'lat' => $result['geometry']['location']['lat'] ?? 0.0, + 'lng' => $result['geometry']['location']['lng'] ?? 0.0, + ], + ], + 'rating' => $result['rating'] ?? 0.0, + 'userRatingsTotal' => $result['user_ratings_total'] ?? 0, + 'priceLevel' => $result['price_level'] ?? 0, + 'phoneNumber' => $result['formatted_phone_number'] ?? '', + 'website' => $result['website'] ?? '', + 'openingHours' => [ + 'openNow' => $result['opening_hours']['open_now'] ?? false, + 'periods' => array_map(fn ($period) => [ + 'open' => [ + 'day' => $period['open']['day'], + 'time' => $period['open']['time'], + ], + 'close' => [ + 'day' => $period['close']['day'] ?? 0, + 'time' => $period['close']['time'] ?? '', + ], + ], $result['opening_hours']['periods'] ?? []), + 'weekdayText' => $result['opening_hours']['weekday_text'] ?? [], + ], + 'reviews' => array_map(fn ($review) => [ + 'authorName' => $review['author_name'], + 'rating' => $review['rating'], + 'text' => $review['text'], + 'time' => $review['time'], + ], $result['reviews'] ?? []), + 'photos' => array_map(fn ($photo) => [ + 'photoReference' => $photo['photo_reference'], + 'height' => $photo['height'], + 'width' => $photo['width'], + ], $result['photos'] ?? []), + 'status' => $data['status'], + ]; + } catch (\Exception $e) { + return [ + 'placeId' => $placeId, + 'name' => '', + 'formattedAddress' => '', + 'geometry' => ['location' => ['lat' => 0.0, 'lng' => 0.0]], + 'rating' => 0.0, + 'userRatingsTotal' => 0, + 'priceLevel' => 0, + 'phoneNumber' => '', + 'website' => '', + 'openingHours' => ['openNow' => false, 'periods' => [], 'weekdayText' => []], + 'reviews' => [], + 'photos' => [], + 'status' => 'ERROR', + ]; + } + } + + /** + * Search nearby places. + * + * @param string $location Location (lat,lng) + * @param int $radius Search radius in meters + * @param string $type Place type filter + * @param string $keyword Keyword filter + * @param string $language Response language + * + * @return array{ + * results: array, + * }>, + * status: string, + * nextPageToken: string, + * } + */ + public function nearbySearch( + string $location, + int $radius = 50000, + string $type = '', + string $keyword = '', + string $language = 'en', + ): array { + try { + $params = [ + 'key' => $this->apiKey, + 'location' => $location, + 'radius' => $radius, + 'language' => $language, + ]; + + if ($type) { + $params['type'] = $type; + } + + if ($keyword) { + $params['keyword'] = $keyword; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/nearbysearch/json", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'results' => array_map(fn ($result) => [ + 'placeId' => $result['place_id'], + 'name' => $result['name'], + 'vicinity' => $result['vicinity'] ?? '', + 'geometry' => [ + 'location' => [ + 'lat' => $result['geometry']['location']['lat'], + 'lng' => $result['geometry']['location']['lng'], + ], + ], + 'rating' => $result['rating'] ?? 0.0, + 'priceLevel' => $result['price_level'] ?? 0, + 'types' => $result['types'], + ], $data['results'] ?? []), + 'status' => $data['status'], + 'nextPageToken' => $data['next_page_token'] ?? '', + ]; + } catch (\Exception $e) { + return [ + 'results' => [], + 'status' => 'ERROR', + 'nextPageToken' => '', + ]; + } + } + + /** + * Provide place autocomplete. + * + * @param string $input Input text + * @param string $location Location bias (lat,lng) + * @param int $radius Radius bias in meters + * @param string $language Response language + * @param array $types Place types filter + * + * @return array{ + * predictions: array, + * terms: array, + * matchedSubstrings: array, + * }>, + * status: string, + * } + */ + public function autocomplete( + string $input, + string $location = '', + int $radius = 200000, + string $language = 'en', + array $types = [], + ): array { + try { + $params = [ + 'key' => $this->apiKey, + 'input' => $input, + 'language' => $language, + ]; + + if ($location) { + $params['location'] = $location; + $params['radius'] = $radius; + } + + if (!empty($types)) { + $params['types'] = implode('|', $types); + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/autocomplete/json", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'predictions' => array_map(fn ($prediction) => [ + 'description' => $prediction['description'], + 'placeId' => $prediction['place_id'], + 'types' => $prediction['types'], + 'terms' => array_map(fn ($term) => [ + 'offset' => $term['offset'], + 'value' => $term['value'], + ], $prediction['terms']), + 'matchedSubstrings' => array_map(fn ($match) => [ + 'length' => $match['length'], + 'offset' => $match['offset'], + ], $prediction['matched_substrings']), + ], $data['predictions'] ?? []), + 'status' => $data['status'], + ]; + } catch (\Exception $e) { + return [ + 'predictions' => [], + 'status' => 'ERROR', + ]; + } + } + + /** + * Geocode addresses. + * + * @param string $address Address to geocode + * @param string $language Response language + * @param string $region Region bias + * + * @return array{ + * results: array, + * placeId: string, + * }>, + * status: string, + * } + */ + public function geocode( + string $address, + string $language = 'en', + string $region = '', + ): array { + try { + $params = [ + 'key' => $this->apiKey, + 'address' => $address, + 'language' => $language, + ]; + + if ($region) { + $params['region'] = $region; + } + + $response = $this->httpClient->request('GET', 'https://maps.googleapis.com/maps/api/geocode/json', [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'results' => array_map(fn ($result) => [ + 'formattedAddress' => $result['formatted_address'], + 'geometry' => [ + 'location' => [ + 'lat' => $result['geometry']['location']['lat'], + 'lng' => $result['geometry']['location']['lng'], + ], + ], + 'types' => $result['types'], + 'placeId' => $result['place_id'], + ], $data['results'] ?? []), + 'status' => $data['status'], + ]; + } catch (\Exception $e) { + return [ + 'results' => [], + 'status' => 'ERROR', + ]; + } + } + + /** + * Reverse geocode coordinates. + * + * @param string $latlng Coordinates (lat,lng) + * @param string $language Response language + * @param array $resultTypes Result type filter + * + * @return array{ + * results: array, + * placeId: string, + * addressComponents: array, + * }>, + * }>, + * status: string, + * } + */ + public function reverseGeocode( + string $latlng, + string $language = 'en', + array $resultTypes = [], + ): array { + try { + $params = [ + 'key' => $this->apiKey, + 'latlng' => $latlng, + 'language' => $language, + ]; + + if (!empty($resultTypes)) { + $params['result_type'] = implode('|', $resultTypes); + } + + $response = $this->httpClient->request('GET', 'https://maps.googleapis.com/maps/api/geocode/json', [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'results' => array_map(fn ($result) => [ + 'formattedAddress' => $result['formatted_address'], + 'geometry' => [ + 'location' => [ + 'lat' => $result['geometry']['location']['lat'], + 'lng' => $result['geometry']['location']['lng'], + ], + ], + 'types' => $result['types'], + 'placeId' => $result['place_id'], + 'addressComponents' => array_map(fn ($component) => [ + 'longName' => $component['long_name'], + 'shortName' => $component['short_name'], + 'types' => $component['types'], + ], $result['address_components']), + ], $data['results'] ?? []), + 'status' => $data['status'], + ]; + } catch (\Exception $e) { + return [ + 'results' => [], + 'status' => 'ERROR', + ]; + } + } + + /** + * Get Google Places photo. + * + * @param string $photoReference Photo reference + * @param int $maxWidth Maximum width + * @param int $maxHeight Maximum height + * + * @return array{ + * photoUrl: string, + * width: int, + * height: int, + * attribution: string, + * } + */ + public function getPhoto( + string $photoReference, + int $maxWidth = 400, + int $maxHeight = 400, + ): array { + try { + $params = [ + 'key' => $this->apiKey, + 'photoreference' => $photoReference, + 'maxwidth' => $maxWidth, + 'maxheight' => $maxHeight, + ]; + + $photoUrl = "{$this->baseUrl}/photo?".http_build_query(array_merge($this->options, $params)); + + return [ + 'photoUrl' => $photoUrl, + 'width' => $maxWidth, + 'height' => $maxHeight, + 'attribution' => 'Photo provided by Google Places API', + ]; + } catch (\Exception $e) { + return [ + 'photoUrl' => '', + 'width' => 0, + 'height' => 0, + 'attribution' => '', + ]; + } + } + + /** + * Get Google Places reviews. + * + * @param string $placeId Place ID + * @param string $language Response language + * + * @return array{ + * reviews: array, + * status: string, + * } + */ + public function getReviews( + string $placeId, + string $language = 'en', + ): array { + try { + $details = $this->getDetails($placeId, ['reviews'], $language); + + return [ + 'reviews' => $details['reviews'], + 'status' => $details['status'], + ]; + } catch (\Exception $e) { + return [ + 'reviews' => [], + 'status' => 'ERROR', + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/GoogleScholar.php b/src/agent/src/Toolbox/Tool/GoogleScholar.php new file mode 100644 index 000000000..d1b8b622e --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GoogleScholar.php @@ -0,0 +1,452 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('google_scholar_search', 'Tool that searches Google Scholar for academic papers')] +#[AsTool('google_scholar_get_citations', 'Tool that gets citation metrics from Google Scholar', method: 'getCitations')] +#[AsTool('google_scholar_get_author_profile', 'Tool that gets author profile from Google Scholar', method: 'getAuthorProfile')] +#[AsTool('google_scholar_get_related_articles', 'Tool that gets related articles from Google Scholar', method: 'getRelatedArticles')] +final readonly class GoogleScholar +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $baseUrl = 'https://scholar.google.com', + private array $options = [], + ) { + } + + /** + * Search Google Scholar for academic papers. + * + * @param string $query Search query + * @param int $num Number of results + * @param int $start Start index + * @param string $sort Sort order (relevance, date) + * @param string $cluster Cluster results + * @param string $asSdt article, thesis, etc + * @param string $asVis Include citations + * @param string $hl Language + * @param string $asRights Access rights filter + * + * @return array{ + * results: array, + * versions: array, + * }>, + * totalResults: int, + * searchTime: float, + * } + */ + public function __invoke( + string $query, + int $num = 10, + int $start = 0, + string $sort = 'relevance', + string $cluster = 'all', + string $asSdt = '0,5', + string $asVis = '1', + string $hl = 'en', + string $asRights = '', + ): array { + try { + $params = [ + 'q' => $query, + 'num' => min(max($num, 1), 20), + 'start' => max($start, 0), + 'hl' => $hl, + 'as_sdt' => $asSdt, + 'as_vis' => $asVis, + 'cluster' => $cluster, + ]; + + if ($sort) { + $params['scisbd'] = 'date' === $sort ? '1' : '0'; + } + + if ($asRights) { + $params['as_rights'] = $asRights; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/scholar", [ + 'query' => array_merge($this->options, $params), + 'headers' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + ], + ]); + + $content = $response->getContent(); + $results = $this->parseSearchResults($content); + + return [ + 'results' => $results, + 'totalResults' => $this->extractTotalResults($content), + 'searchTime' => 0.0, // Google Scholar doesn't provide this + ]; + } catch (\Exception $e) { + return [ + 'results' => [], + 'totalResults' => 0, + 'searchTime' => 0.0, + ]; + } + } + + /** + * Get citation metrics from Google Scholar. + * + * @param string $paperTitle Paper title + * @param string $authors Authors + * @param string $year Publication year + * + * @return array{ + * title: string, + * authors: string, + * year: string, + * citations: int, + * hIndex: int, + * i10Index: int, + * relatedPapers: array, + * } + */ + public function getCitations( + string $paperTitle, + string $authors = '', + string $year = '', + ): array { + try { + $searchQuery = $paperTitle; + if ($authors) { + $searchQuery .= ' '.$authors; + } + if ($year) { + $searchQuery .= ' '.$year; + } + + $params = [ + 'q' => $searchQuery, + 'num' => 1, + 'hl' => 'en', + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/scholar", [ + 'query' => array_merge($this->options, $params), + 'headers' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + ], + ]); + + $content = $response->getContent(); + $citations = $this->extractCitationMetrics($content); + + return [ + 'title' => $paperTitle, + 'authors' => $authors, + 'year' => $year, + 'citations' => $citations['citations'] ?? 0, + 'hIndex' => $citations['hIndex'] ?? 0, + 'i10Index' => $citations['i10Index'] ?? 0, + 'relatedPapers' => $citations['relatedPapers'] ?? [], + ]; + } catch (\Exception $e) { + return [ + 'title' => $paperTitle, + 'authors' => $authors, + 'year' => $year, + 'citations' => 0, + 'hIndex' => 0, + 'i10Index' => 0, + 'relatedPapers' => [], + ]; + } + } + + /** + * Get author profile from Google Scholar. + * + * @param string $authorName Author name + * @param string $affiliation Institution affiliation + * @param string $email Author email + * + * @return array{ + * name: string, + * affiliation: string, + * email: string, + * homepage: string, + * interests: array, + * citations: int, + * hIndex: int, + * i10Index: int, + * papers: array, + * } + */ + public function getAuthorProfile( + string $authorName, + string $affiliation = '', + string $email = '', + ): array { + try { + $searchQuery = $authorName; + if ($affiliation) { + $searchQuery .= ' '.$affiliation; + } + + $params = [ + 'q' => $searchQuery, + 'num' => 1, + 'hl' => 'en', + 'view_op' => 'search_authors', + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/scholar", [ + 'query' => array_merge($this->options, $params), + 'headers' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + ], + ]); + + $content = $response->getContent(); + $profile = $this->extractAuthorProfile($content); + + return [ + 'name' => $authorName, + 'affiliation' => $affiliation, + 'email' => $email, + 'homepage' => $profile['homepage'] ?? '', + 'interests' => $profile['interests'] ?? [], + 'citations' => $profile['citations'] ?? 0, + 'hIndex' => $profile['hIndex'] ?? 0, + 'i10Index' => $profile['i10Index'] ?? 0, + 'papers' => $profile['papers'] ?? [], + ]; + } catch (\Exception $e) { + return [ + 'name' => $authorName, + 'affiliation' => $affiliation, + 'email' => $email, + 'homepage' => '', + 'interests' => [], + 'citations' => 0, + 'hIndex' => 0, + 'i10Index' => 0, + 'papers' => [], + ]; + } + } + + /** + * Get related articles from Google Scholar. + * + * @param string $paperTitle Paper title + * @param string $authors Authors + * @param int $num Number of related articles + * + * @return array{ + * originalPaper: array{ + * title: string, + * authors: string, + * year: string, + * }, + * relatedArticles: array, + * } + */ + public function getRelatedArticles( + string $paperTitle, + string $authors = '', + int $num = 10, + ): array { + try { + $searchQuery = $paperTitle; + if ($authors) { + $searchQuery .= ' '.$authors; + } + + $params = [ + 'q' => $searchQuery, + 'num' => min(max($num, 1), 20), + 'hl' => 'en', + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/scholar", [ + 'query' => array_merge($this->options, $params), + 'headers' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + ], + ]); + + $content = $response->getContent(); + $results = $this->parseSearchResults($content); + + return [ + 'originalPaper' => [ + 'title' => $paperTitle, + 'authors' => $authors, + 'year' => '', + ], + 'relatedArticles' => array_map(fn ($result) => [ + 'title' => $result['title'], + 'authors' => $result['authors'], + 'year' => $result['year'], + 'citations' => $result['citedBy'], + 'relevanceScore' => 0.8, // Simplified relevance score + 'url' => $result['url'], + ], \array_slice($results, 1, $num)), // Skip first result (original paper) + ]; + } catch (\Exception $e) { + return [ + 'originalPaper' => [ + 'title' => $paperTitle, + 'authors' => $authors, + 'year' => '', + ], + 'relatedArticles' => [], + ]; + } + } + + /** + * Parse search results from HTML content. + */ + private function parseSearchResults(string $content): array + { + $results = []; + + // This is a simplified parser - in reality, you'd need more sophisticated HTML parsing + preg_match_all('/]*class="[^"]*gs_rt[^"]*"[^>]*>(.*?)<\/h3>/s', $content, $titleMatches); + preg_match_all('/]*class="[^"]*gs_a[^"]*"[^>]*>(.*?)<\/div>/s', $content, $authorMatches); + preg_match_all('/]*class="[^"]*gs_rs[^"]*"[^>]*>(.*?)<\/div>/s', $content, $snippetMatches); + + for ($i = 0; $i < min(\count($titleMatches[1]), 10); ++$i) { + $title = strip_tags($titleMatches[1][$i]); + $authors = isset($authorMatches[1][$i]) ? strip_tags($authorMatches[1][$i]) : ''; + $snippet = isset($snippetMatches[1][$i]) ? strip_tags($snippetMatches[1][$i]) : ''; + + $results[] = [ + 'title' => $title, + 'authors' => $authors, + 'publication' => '', + 'year' => '', + 'citedBy' => 0, + 'pdfLink' => '', + 'url' => '', + 'snippet' => $snippet, + 'related' => [], + 'versions' => [], + ]; + } + + return $results; + } + + /** + * Extract total results count from HTML content. + */ + private function extractTotalResults(string $content): int + { + preg_match('/About ([\d,]+) results/', $content, $matches); + + return isset($matches[1]) ? (int) str_replace(',', '', $matches[1]) : 0; + } + + /** + * Extract citation metrics from HTML content. + */ + private function extractCitationMetrics(string $content): array + { + $citations = 0; + $hIndex = 0; + $i10Index = 0; + $relatedPapers = []; + + preg_match('/Cited by ([\d,]+)/', $content, $citationMatches); + if (isset($citationMatches[1])) { + $citations = (int) str_replace(',', '', $citationMatches[1]); + } + + return [ + 'citations' => $citations, + 'hIndex' => $hIndex, + 'i10Index' => $i10Index, + 'relatedPapers' => $relatedPapers, + ]; + } + + /** + * Extract author profile from HTML content. + */ + private function extractAuthorProfile(string $content): array + { + $homepage = ''; + $interests = []; + $citations = 0; + $hIndex = 0; + $i10Index = 0; + $papers = []; + + preg_match('/]*href="([^"]*)"[^>]*>Homepage<\/a>/', $content, $homepageMatches); + if (isset($homepageMatches[1])) { + $homepage = $homepageMatches[1]; + } + + preg_match('/Citations: ([\d,]+)/', $content, $citationMatches); + if (isset($citationMatches[1])) { + $citations = (int) str_replace(',', '', $citationMatches[1]); + } + + return [ + 'homepage' => $homepage, + 'interests' => $interests, + 'citations' => $citations, + 'hIndex' => $hIndex, + 'i10Index' => $i10Index, + 'papers' => $papers, + ]; + } +} diff --git a/src/agent/src/Toolbox/Tool/GoogleSearch.php b/src/agent/src/Toolbox/Tool/GoogleSearch.php new file mode 100644 index 000000000..afa42dfae --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GoogleSearch.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('google_search', 'Tool that searches the web using Google Search API')] +#[AsTool('google_search_results_json', 'Tool that searches Google and returns structured JSON results', method: 'searchJson')] +final readonly class GoogleSearch +{ + /** + * @param array $options Additional search options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + #[\SensitiveParameter] private string $searchEngineId, + private array $options = [], + ) { + } + + /** + * @param string $query the search query term + * @param int $count The number of search results returned in response. + * Combine this parameter with offset to paginate search results. + * @param int $offset The number of search results to skip before returning results. + * In order to paginate results use this parameter together with count. + * + * @return array + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + int $count = 10, + #[With(minimum: 0, maximum: 9)] + int $offset = 0, + ): array { + $result = $this->httpClient->request('GET', 'https://www.googleapis.com/customsearch/v1', [ + 'query' => array_merge($this->options, [ + 'key' => $this->apiKey, + 'cx' => $this->searchEngineId, + 'q' => $query, + 'num' => $count, + 'start' => $offset + 1, + ]), + ]); + + $data = $result->toArray(); + + return array_map(static function (array $result) { + return [ + 'title' => $result['title'] ?? '', + 'snippet' => $result['snippet'] ?? '', + 'link' => $result['link'] ?? '', + ]; + }, $data['items'] ?? []); + } + + /** + * @param string $query the search query term + * @param int $numResults The number of search results to return + * + * @return array + */ + public function searchJson( + #[With(maximum: 500)] + string $query, + int $numResults = 4, + ): array { + return $this->__invoke($query, $numResults); + } +} diff --git a/src/agent/src/Toolbox/Tool/GoogleSerper.php b/src/agent/src/Toolbox/Tool/GoogleSerper.php new file mode 100644 index 000000000..aad99de1a --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GoogleSerper.php @@ -0,0 +1,646 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('google_serper_search', 'Tool that searches Google via Serper API')] +#[AsTool('google_serper_image_search', 'Tool that searches Google Images via Serper API', method: 'imageSearch')] +#[AsTool('google_serper_news_search', 'Tool that searches Google News via Serper API', method: 'newsSearch')] +#[AsTool('google_serper_place_search', 'Tool that searches Google Places via Serper API', method: 'placeSearch')] +#[AsTool('google_serper_shopping_search', 'Tool that searches Google Shopping via Serper API', method: 'shoppingSearch')] +final readonly class GoogleSerper +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $baseUrl = 'https://google.serper.dev', + private array $options = [], + ) { + } + + /** + * Search Google via Serper API. + * + * @param string $query Search query + * @param int $num Number of results + * @param int $start Start index + * @param string $gl Country code + * @param string $hl Language code + * @param string $safe Safe search + * + * @return array{ + * searchParameters: array{ + * q: string, + * type: string, + * engine: string, + * gl: string, + * hl: string, + * num: int, + * start: int, + * }, + * organic: array, + * }>, + * knowledgeGraph: array{ + * title: string, + * type: string, + * website: string, + * imageUrl: string, + * description: string, + * descriptionSource: string, + * descriptionLink: string, + * attributes: array, + * }|null, + * answerBox: array{ + * answer: string, + * title: string, + * link: string, + * snippet: string, + * date: string, + * }|null, + * peopleAlsoAsk: array, + * relatedSearches: array, + * } + */ + public function __invoke( + string $query, + int $num = 10, + int $start = 0, + string $gl = '', + string $hl = '', + string $safe = 'off', + ): array { + try { + $body = [ + 'q' => $query, + 'num' => min(max($num, 1), 100), + 'start' => max($start, 0), + 'safe' => $safe, + ]; + + if ($gl) { + $body['gl'] = $gl; + } + if ($hl) { + $body['hl'] = $hl; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/search", [ + 'headers' => [ + 'X-API-KEY' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $body, + ]); + + $data = $response->toArray(); + + return [ + 'searchParameters' => [ + 'q' => $data['searchParameters']['q'] ?? $query, + 'type' => $data['searchParameters']['type'] ?? 'search', + 'engine' => $data['searchParameters']['engine'] ?? 'google', + 'gl' => $data['searchParameters']['gl'] ?? $gl, + 'hl' => $data['searchParameters']['hl'] ?? $hl, + 'num' => $data['searchParameters']['num'] ?? $num, + 'start' => $data['searchParameters']['start'] ?? $start, + ], + 'organic' => array_map(fn ($result) => [ + 'title' => $result['title'], + 'link' => $result['link'], + 'snippet' => $result['snippet'] ?? '', + 'position' => $result['position'], + 'date' => $result['date'] ?? '', + 'sitelinks' => array_map(fn ($link) => [ + 'title' => $link['title'], + 'link' => $link['link'], + ], $result['sitelinks'] ?? []), + ], $data['organic'] ?? []), + 'knowledgeGraph' => $data['knowledgeGraph'] ? [ + 'title' => $data['knowledgeGraph']['title'], + 'type' => $data['knowledgeGraph']['type'], + 'website' => $data['knowledgeGraph']['website'] ?? '', + 'imageUrl' => $data['knowledgeGraph']['imageUrl'] ?? '', + 'description' => $data['knowledgeGraph']['description'] ?? '', + 'descriptionSource' => $data['knowledgeGraph']['descriptionSource'] ?? '', + 'descriptionLink' => $data['knowledgeGraph']['descriptionLink'] ?? '', + 'attributes' => $data['knowledgeGraph']['attributes'] ?? [], + ] : null, + 'answerBox' => $data['answerBox'] ? [ + 'answer' => $data['answerBox']['answer'], + 'title' => $data['answerBox']['title'] ?? '', + 'link' => $data['answerBox']['link'] ?? '', + 'snippet' => $data['answerBox']['snippet'] ?? '', + 'date' => $data['answerBox']['date'] ?? '', + ] : null, + 'peopleAlsoAsk' => array_map(fn ($item) => [ + 'question' => $item['question'], + 'snippet' => $item['snippet'], + 'title' => $item['title'] ?? '', + 'link' => $item['link'] ?? '', + ], $data['peopleAlsoAsk'] ?? []), + 'relatedSearches' => array_map(fn ($search) => [ + 'query' => $search['query'], + ], $data['relatedSearches'] ?? []), + ]; + } catch (\Exception $e) { + return [ + 'searchParameters' => [ + 'q' => $query, + 'type' => 'search', + 'engine' => 'google', + 'gl' => $gl, + 'hl' => $hl, + 'num' => $num, + 'start' => $start, + ], + 'organic' => [], + 'knowledgeGraph' => null, + 'answerBox' => null, + 'peopleAlsoAsk' => [], + 'relatedSearches' => [], + ]; + } + } + + /** + * Search Google Images via Serper API. + * + * @param string $query Search query + * @param int $num Number of results + * @param int $start Start index + * @param string $gl Country code + * @param string $hl Language code + * @param string $safe Safe search + * @param string $imageType Image type filter + * @param string $color Color filter + * + * @return array{ + * searchParameters: array{ + * q: string, + * type: string, + * engine: string, + * gl: string, + * hl: string, + * num: int, + * start: int, + * }, + * images: array, + * } + */ + public function imageSearch( + string $query, + int $num = 10, + int $start = 0, + string $gl = '', + string $hl = '', + string $safe = 'off', + string $imageType = '', + string $color = '', + ): array { + try { + $body = [ + 'q' => $query, + 'num' => min(max($num, 1), 100), + 'start' => max($start, 0), + 'safe' => $safe, + ]; + + if ($gl) { + $body['gl'] = $gl; + } + if ($hl) { + $body['hl'] = $hl; + } + if ($imageType) { + $body['imageType'] = $imageType; + } + if ($color) { + $body['color'] = $color; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/images", [ + 'headers' => [ + 'X-API-KEY' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $body, + ]); + + $data = $response->toArray(); + + return [ + 'searchParameters' => [ + 'q' => $data['searchParameters']['q'] ?? $query, + 'type' => $data['searchParameters']['type'] ?? 'images', + 'engine' => $data['searchParameters']['engine'] ?? 'google', + 'gl' => $data['searchParameters']['gl'] ?? $gl, + 'hl' => $data['searchParameters']['hl'] ?? $hl, + 'num' => $data['searchParameters']['num'] ?? $num, + 'start' => $data['searchParameters']['start'] ?? $start, + ], + 'images' => array_map(fn ($image) => [ + 'title' => $image['title'], + 'imageUrl' => $image['imageUrl'], + 'imageWidth' => $image['imageWidth'], + 'imageHeight' => $image['imageHeight'], + 'thumbnailUrl' => $image['thumbnailUrl'], + 'thumbnailWidth' => $image['thumbnailWidth'], + 'thumbnailHeight' => $image['thumbnailHeight'], + 'source' => $image['source'], + 'domain' => $image['domain'], + 'link' => $image['link'], + 'googleUrl' => $image['googleUrl'], + 'position' => $image['position'], + ], $data['images'] ?? []), + ]; + } catch (\Exception $e) { + return [ + 'searchParameters' => [ + 'q' => $query, + 'type' => 'images', + 'engine' => 'google', + 'gl' => $gl, + 'hl' => $hl, + 'num' => $num, + 'start' => $start, + ], + 'images' => [], + ]; + } + } + + /** + * Search Google News via Serper API. + * + * @param string $query Search query + * @param int $num Number of results + * @param int $start Start index + * @param string $gl Country code + * @param string $hl Language code + * @param string $sort Sort order + * @param string $when Time filter + * + * @return array{ + * searchParameters: array{ + * q: string, + * type: string, + * engine: string, + * gl: string, + * hl: string, + * num: int, + * start: int, + * }, + * news: array, + * } + */ + public function newsSearch( + string $query, + int $num = 10, + int $start = 0, + string $gl = '', + string $hl = '', + string $sort = '', + string $when = '', + ): array { + try { + $body = [ + 'q' => $query, + 'num' => min(max($num, 1), 100), + 'start' => max($start, 0), + ]; + + if ($gl) { + $body['gl'] = $gl; + } + if ($hl) { + $body['hl'] = $hl; + } + if ($sort) { + $body['sort'] = $sort; + } + if ($when) { + $body['when'] = $when; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/news", [ + 'headers' => [ + 'X-API-KEY' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $body, + ]); + + $data = $response->toArray(); + + return [ + 'searchParameters' => [ + 'q' => $data['searchParameters']['q'] ?? $query, + 'type' => $data['searchParameters']['type'] ?? 'news', + 'engine' => $data['searchParameters']['engine'] ?? 'google', + 'gl' => $data['searchParameters']['gl'] ?? $gl, + 'hl' => $data['searchParameters']['hl'] ?? $hl, + 'num' => $data['searchParameters']['num'] ?? $num, + 'start' => $data['searchParameters']['start'] ?? $start, + ], + 'news' => array_map(fn ($news) => [ + 'title' => $news['title'], + 'link' => $news['link'], + 'snippet' => $news['snippet'] ?? '', + 'date' => $news['date'] ?? '', + 'position' => $news['position'], + 'source' => $news['source'] ?? '', + 'imageUrl' => $news['imageUrl'] ?? '', + ], $data['news'] ?? []), + ]; + } catch (\Exception $e) { + return [ + 'searchParameters' => [ + 'q' => $query, + 'type' => 'news', + 'engine' => 'google', + 'gl' => $gl, + 'hl' => $hl, + 'num' => $num, + 'start' => $start, + ], + 'news' => [], + ]; + } + } + + /** + * Search Google Places via Serper API. + * + * @param string $query Search query + * @param string $location Location filter + * @param int $num Number of results + * @param string $gl Country code + * @param string $hl Language code + * + * @return array{ + * searchParameters: array{ + * q: string, + * type: string, + * engine: string, + * gl: string, + * hl: string, + * num: int, + * }, + * places: array, + * position: int, + * }>, + * } + */ + public function placeSearch( + string $query, + string $location = '', + int $num = 10, + string $gl = '', + string $hl = '', + ): array { + try { + $body = [ + 'q' => $query, + 'num' => min(max($num, 1), 100), + ]; + + if ($location) { + $body['location'] = $location; + } + if ($gl) { + $body['gl'] = $gl; + } + if ($hl) { + $body['hl'] = $hl; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/places", [ + 'headers' => [ + 'X-API-KEY' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $body, + ]); + + $data = $response->toArray(); + + return [ + 'searchParameters' => [ + 'q' => $data['searchParameters']['q'] ?? $query, + 'type' => $data['searchParameters']['type'] ?? 'places', + 'engine' => $data['searchParameters']['engine'] ?? 'google', + 'gl' => $data['searchParameters']['gl'] ?? $gl, + 'hl' => $data['searchParameters']['hl'] ?? $hl, + 'num' => $data['searchParameters']['num'] ?? $num, + ], + 'places' => array_map(fn ($place) => [ + 'title' => $place['title'], + 'address' => $place['address'], + 'latitude' => $place['latitude'], + 'longitude' => $place['longitude'], + 'rating' => $place['rating'] ?? 0.0, + 'reviews' => $place['reviews'] ?? 0, + 'type' => $place['type'] ?? '', + 'website' => $place['website'] ?? '', + 'phone' => $place['phone'] ?? '', + 'hours' => $place['hours'] ?? [], + 'position' => $place['position'], + ], $data['places'] ?? []), + ]; + } catch (\Exception $e) { + return [ + 'searchParameters' => [ + 'q' => $query, + 'type' => 'places', + 'engine' => 'google', + 'gl' => $gl, + 'hl' => $hl, + 'num' => $num, + ], + 'places' => [], + ]; + } + } + + /** + * Search Google Shopping via Serper API. + * + * @param string $query Search query + * @param int $num Number of results + * @param int $start Start index + * @param string $gl Country code + * @param string $hl Language code + * @param string $sort Sort order + * @param string $priceMin Minimum price + * @param string $priceMax Maximum price + * + * @return array{ + * searchParameters: array{ + * q: string, + * type: string, + * engine: string, + * gl: string, + * hl: string, + * num: int, + * start: int, + * }, + * shopping: array, + * thumbnail: string, + * position: int, + * delivery: string, + * }>, + * } + */ + public function shoppingSearch( + string $query, + int $num = 10, + int $start = 0, + string $gl = '', + string $hl = '', + string $sort = '', + string $priceMin = '', + string $priceMax = '', + ): array { + try { + $body = [ + 'q' => $query, + 'num' => min(max($num, 1), 100), + 'start' => max($start, 0), + ]; + + if ($gl) { + $body['gl'] = $gl; + } + if ($hl) { + $body['hl'] = $hl; + } + if ($sort) { + $body['sort'] = $sort; + } + if ($priceMin) { + $body['priceMin'] = $priceMin; + } + if ($priceMax) { + $body['priceMax'] = $priceMax; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/shopping", [ + 'headers' => [ + 'X-API-KEY' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $body, + ]); + + $data = $response->toArray(); + + return [ + 'searchParameters' => [ + 'q' => $data['searchParameters']['q'] ?? $query, + 'type' => $data['searchParameters']['type'] ?? 'shopping', + 'engine' => $data['searchParameters']['engine'] ?? 'google', + 'gl' => $data['searchParameters']['gl'] ?? $gl, + 'hl' => $data['searchParameters']['hl'] ?? $hl, + 'num' => $data['searchParameters']['num'] ?? $num, + 'start' => $data['searchParameters']['start'] ?? $start, + ], + 'shopping' => array_map(fn ($item) => [ + 'title' => $item['title'], + 'link' => $item['link'], + 'price' => $item['price'], + 'extractedPrice' => $item['extractedPrice'] ?? 0.0, + 'rating' => $item['rating'] ?? 0.0, + 'reviews' => $item['reviews'] ?? 0, + 'extensions' => $item['extensions'] ?? [], + 'thumbnail' => $item['thumbnail'] ?? '', + 'position' => $item['position'], + 'delivery' => $item['delivery'] ?? '', + ], $data['shopping'] ?? []), + ]; + } catch (\Exception $e) { + return [ + 'searchParameters' => [ + 'q' => $query, + 'type' => 'shopping', + 'engine' => 'google', + 'gl' => $gl, + 'hl' => $hl, + 'num' => $num, + 'start' => $start, + ], + 'shopping' => [], + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/GoogleTrends.php b/src/agent/src/Toolbox/Tool/GoogleTrends.php new file mode 100644 index 000000000..18652f8cb --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GoogleTrends.php @@ -0,0 +1,590 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('google_trends_get_interest_over_time', 'Tool that gets Google Trends interest over time')] +#[AsTool('google_trends_get_interest_by_region', 'Tool that gets Google Trends interest by region', method: 'getInterestByRegion')] +#[AsTool('google_trends_get_related_queries', 'Tool that gets Google Trends related queries', method: 'getRelatedQueries')] +#[AsTool('google_trends_get_related_topics', 'Tool that gets Google Trends related topics', method: 'getRelatedTopics')] +#[AsTool('google_trends_get_realtime_trending_searches', 'Tool that gets realtime trending searches', method: 'getRealtimeTrendingSearches')] +#[AsTool('google_trends_get_daily_trending_searches', 'Tool that gets daily trending searches', method: 'getDailyTrendingSearches')] +final readonly class GoogleTrends +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $baseUrl = 'https://trends.google.com/trends/api', + private array $options = [], + ) { + } + + /** + * Get Google Trends interest over time. + * + * @param array $keywords Keywords to search for + * @param string $startDate Start date (YYYY-MM-DD) + * @param string $endDate End date (YYYY-MM-DD) + * @param string $geo Geographic location (country code) + * @param string $category Search category + * @param string $gprop Google property (web, images, news, youtube, froogle) + * + * @return array{ + * timelineData: array, + * formattedValue: array, + * }>, + * keywords: array, + * geo: string, + * category: int, + * } + */ + public function __invoke( + array $keywords, + string $startDate = '', + string $endDate = '', + string $geo = '', + string $category = '', + string $gprop = 'web', + ): array { + try { + $params = [ + 'hl' => 'en', + 'tz' => '-480', + 'req' => json_encode([ + 'time' => $this->buildTimeParam($startDate, $endDate), + 'resolution' => 'DAY', + 'locale' => 'en', + 'comparisonItem' => array_map(fn ($keyword) => [ + 'keyword' => $keyword, + 'geo' => $geo, + 'time' => $this->buildTimeParam($startDate, $endDate), + ], $keywords), + 'requestOptions' => [ + 'property' => $gprop, + 'backend' => 'IZG', + 'category' => $this->getCategoryId($category), + ], + ]), + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/explore", [ + 'query' => array_merge($this->options, $params), + ]); + + $content = $response->getContent(); + $content = substr($content, 4); // Remove ')]}\n' prefix + $data = json_decode($content, true); + + if (!$data || !isset($data['default']['timelineData'])) { + return [ + 'timelineData' => [], + 'keywords' => $keywords, + 'geo' => $geo, + 'category' => 0, + ]; + } + + return [ + 'timelineData' => array_map(fn ($item) => [ + 'time' => $item['time'], + 'formattedTime' => $item['formattedTime'], + 'value' => $item['value'], + 'formattedValue' => $item['formattedValue'], + ], $data['default']['timelineData']), + 'keywords' => $keywords, + 'geo' => $geo, + 'category' => $this->getCategoryId($category), + ]; + } catch (\Exception $e) { + return [ + 'timelineData' => [], + 'keywords' => $keywords, + 'geo' => $geo, + 'category' => 0, + ]; + } + } + + /** + * Get Google Trends interest by region. + * + * @param array $keywords Keywords to search for + * @param string $startDate Start date (YYYY-MM-DD) + * @param string $endDate End date (YYYY-MM-DD) + * @param string $geo Geographic location (country code) + * @param string $category Search category + * @param string $gprop Google property + * @param int $resolution Resolution (COUNTRY, REGION, CITY) + * + * @return array{ + * geoMapData: array, + * formattedValue: array, + * hasData: array, + * maxValueIndex: int, + * }>, + * keywords: array, + * geo: string, + * category: int, + * } + */ + public function getInterestByRegion( + array $keywords, + string $startDate = '', + string $endDate = '', + string $geo = '', + string $category = '', + string $gprop = 'web', + int $resolution = 0, + ): array { + try { + $resolutionMap = [0 => 'COUNTRY', 1 => 'REGION', 2 => 'CITY']; + $resolutionStr = $resolutionMap[$resolution] ?? 'COUNTRY'; + + $params = [ + 'hl' => 'en', + 'tz' => '-480', + 'req' => json_encode([ + 'time' => $this->buildTimeParam($startDate, $endDate), + 'resolution' => $resolutionStr, + 'locale' => 'en', + 'comparisonItem' => array_map(fn ($keyword) => [ + 'keyword' => $keyword, + 'geo' => $geo, + 'time' => $this->buildTimeParam($startDate, $endDate), + ], $keywords), + 'requestOptions' => [ + 'property' => $gprop, + 'backend' => 'IZG', + 'category' => $this->getCategoryId($category), + ], + ]), + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/explore", [ + 'query' => array_merge($this->options, $params), + ]); + + $content = $response->getContent(); + $content = substr($content, 4); + $data = json_decode($content, true); + + if (!$data || !isset($data['default']['geoMapData'])) { + return [ + 'geoMapData' => [], + 'keywords' => $keywords, + 'geo' => $geo, + 'category' => 0, + ]; + } + + return [ + 'geoMapData' => array_map(fn ($item) => [ + 'geoCode' => $item['geoCode'], + 'geoName' => $item['geoName'], + 'value' => $item['value'], + 'formattedValue' => $item['formattedValue'], + 'hasData' => $item['hasData'], + 'maxValueIndex' => $item['maxValueIndex'], + ], $data['default']['geoMapData']), + 'keywords' => $keywords, + 'geo' => $geo, + 'category' => $this->getCategoryId($category), + ]; + } catch (\Exception $e) { + return [ + 'geoMapData' => [], + 'keywords' => $keywords, + 'geo' => $geo, + 'category' => 0, + ]; + } + } + + /** + * Get Google Trends related queries. + * + * @param array $keywords Keywords to search for + * @param string $startDate Start date (YYYY-MM-DD) + * @param string $endDate End date (YYYY-MM-DD) + * @param string $geo Geographic location + * @param string $category Search category + * @param string $gprop Google property + * @param string $rankType Rank type (rising, top) + * + * @return array{ + * relatedQueries: array, + * keywords: array, + * geo: string, + * category: int, + * } + */ + public function getRelatedQueries( + array $keywords, + string $startDate = '', + string $endDate = '', + string $geo = '', + string $category = '', + string $gprop = 'web', + string $rankType = 'rising', + ): array { + try { + $params = [ + 'hl' => 'en', + 'tz' => '-480', + 'req' => json_encode([ + 'time' => $this->buildTimeParam($startDate, $endDate), + 'locale' => 'en', + 'comparisonItem' => array_map(fn ($keyword) => [ + 'keyword' => $keyword, + 'geo' => $geo, + 'time' => $this->buildTimeParam($startDate, $endDate), + ], $keywords), + 'requestOptions' => [ + 'property' => $gprop, + 'backend' => 'IZG', + 'category' => $this->getCategoryId($category), + ], + ]), + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/explore", [ + 'query' => array_merge($this->options, $params), + ]); + + $content = $response->getContent(); + $content = substr($content, 4); + $data = json_decode($content, true); + + $relatedQueries = []; + if (isset($data['default']['relatedQueries'])) { + $queries = $data['default']['relatedQueries']; + $rankKey = 'rising' === $rankType ? 'rising' : 'top'; + + if (isset($queries['rankedList'][0]['rankedKeyword'])) { + $relatedQueries = array_map(fn ($item) => [ + 'query' => $item['query'], + 'value' => $item['value'], + 'formattedValue' => $item['formattedValue'], + 'link' => $item['link'], + ], $queries['rankedList'][0]['rankedKeyword']); + } + } + + return [ + 'relatedQueries' => $relatedQueries, + 'keywords' => $keywords, + 'geo' => $geo, + 'category' => $this->getCategoryId($category), + ]; + } catch (\Exception $e) { + return [ + 'relatedQueries' => [], + 'keywords' => $keywords, + 'geo' => $geo, + 'category' => 0, + ]; + } + } + + /** + * Get Google Trends related topics. + * + * @param array $keywords Keywords to search for + * @param string $startDate Start date (YYYY-MM-DD) + * @param string $endDate End date (YYYY-MM-DD) + * @param string $geo Geographic location + * @param string $category Search category + * @param string $gprop Google property + * @param string $rankType Rank type (rising, top) + * + * @return array{ + * relatedTopics: array, + * keywords: array, + * geo: string, + * category: int, + * } + */ + public function getRelatedTopics( + array $keywords, + string $startDate = '', + string $endDate = '', + string $geo = '', + string $category = '', + string $gprop = 'web', + string $rankType = 'rising', + ): array { + try { + $params = [ + 'hl' => 'en', + 'tz' => '-480', + 'req' => json_encode([ + 'time' => $this->buildTimeParam($startDate, $endDate), + 'locale' => 'en', + 'comparisonItem' => array_map(fn ($keyword) => [ + 'keyword' => $keyword, + 'geo' => $geo, + 'time' => $this->buildTimeParam($startDate, $endDate), + ], $keywords), + 'requestOptions' => [ + 'property' => $gprop, + 'backend' => 'IZG', + 'category' => $this->getCategoryId($category), + ], + ]), + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/explore", [ + 'query' => array_merge($this->options, $params), + ]); + + $content = $response->getContent(); + $content = substr($content, 4); + $data = json_decode($content, true); + + $relatedTopics = []; + if (isset($data['default']['relatedTopics'])) { + $topics = $data['default']['relatedTopics']; + $rankKey = 'rising' === $rankType ? 'rising' : 'top'; + + if (isset($topics['rankedList'][0]['rankedKeyword'])) { + $relatedTopics = array_map(fn ($item) => [ + 'topic' => $item['topic']['mid'], + 'type' => $item['topic']['type'], + 'value' => $item['value'], + 'formattedValue' => $item['formattedValue'], + 'link' => $item['link'], + ], $topics['rankedList'][0]['rankedKeyword']); + } + } + + return [ + 'relatedTopics' => $relatedTopics, + 'keywords' => $keywords, + 'geo' => $geo, + 'category' => $this->getCategoryId($category), + ]; + } catch (\Exception $e) { + return [ + 'relatedTopics' => [], + 'keywords' => $keywords, + 'geo' => $geo, + 'category' => 0, + ]; + } + } + + /** + * Get realtime trending searches. + * + * @param string $geo Geographic location + * @param string $category Search category + * @param int $count Number of results + * + * @return array{ + * trendingSearches: array, + * image: array{ + * newsUrl: string, + * source: string, + * imageUrl: string, + * }, + * articles: array, + * }>, + * geo: string, + * category: int, + * } + */ + public function getRealtimeTrendingSearches( + string $geo = 'US', + string $category = '', + int $count = 20, + ): array { + try { + $params = [ + 'hl' => 'en', + 'tz' => '-480', + 'cat' => $this->getCategoryId($category), + 'fi' => 0, + 'fs' => 0, + 'geo' => $geo, + 'ri' => 300, + 'rs' => $count, + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/realtimetrends", [ + 'query' => array_merge($this->options, $params), + ]); + + $content = $response->getContent(); + $content = substr($content, 4); + $data = json_decode($content, true); + + $trendingSearches = []; + if (isset($data['default']['trendingSearchesDays'][0]['trendingSearches'])) { + $trendingSearches = array_map(fn ($item) => [ + 'title' => $item['title']['query'], + 'formattedTraffic' => $item['formattedTraffic'], + 'relatedQueries' => array_map(fn ($query) => $query['query'], $item['relatedQueries']), + 'image' => [ + 'newsUrl' => $item['image']['newsUrl'], + 'source' => $item['image']['source'], + 'imageUrl' => $item['image']['imageUrl'], + ], + 'articles' => array_map(fn ($article) => [ + 'title' => $article['title'], + 'timeAgo' => $article['timeAgo'], + 'source' => $article['source'], + 'url' => $article['url'], + 'snippet' => $article['snippet'], + ], $item['articles']), + ], $data['default']['trendingSearchesDays'][0]['trendingSearches']); + } + + return [ + 'trendingSearches' => $trendingSearches, + 'geo' => $geo, + 'category' => $this->getCategoryId($category), + ]; + } catch (\Exception $e) { + return [ + 'trendingSearches' => [], + 'geo' => $geo, + 'category' => 0, + ]; + } + } + + /** + * Get daily trending searches. + * + * @param string $geo Geographic location + * @param string $date Date (YYYY-MM-DD) + * + * @return array{ + * trendingSearches: array, + * geo: string, + * date: string, + * } + */ + public function getDailyTrendingSearches( + string $geo = 'US', + string $date = '', + ): array { + try { + if (!$date) { + $date = date('Y-m-d'); + } + + $params = [ + 'hl' => 'en', + 'tz' => '-480', + 'geo' => $geo, + 'ns' => 15, + 'date' => $date, + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/dailytrends", [ + 'query' => array_merge($this->options, $params), + ]); + + $content = $response->getContent(); + $content = substr($content, 4); + $data = json_decode($content, true); + + $trendingSearches = []; + if (isset($data['default']['trendingSearchesDays'][0]['trendingSearches'])) { + $trendingSearches = array_map(fn ($item) => [ + 'query' => $item['title']['query'], + 'exploreLink' => $item['title']['exploreLink'], + ], $data['default']['trendingSearchesDays'][0]['trendingSearches']); + } + + return [ + 'trendingSearches' => $trendingSearches, + 'geo' => $geo, + 'date' => $date, + ]; + } catch (\Exception $e) { + return [ + 'trendingSearches' => [], + 'geo' => $geo, + 'date' => $date, + ]; + } + } + + /** + * Build time parameter for Google Trends API. + */ + private function buildTimeParam(string $startDate, string $endDate): string + { + if (!$startDate || !$endDate) { + return date('Y-m-d').' '.date('Y-m-d'); + } + + return "{$startDate} {$endDate}"; + } + + /** + * Get category ID from category name. + */ + private function getCategoryId(string $category): int + { + $categories = [ + 'all' => 0, + 'business' => 7, + 'entertainment' => 3, + 'health' => 45, + 'sports' => 20, + 'technology' => 5, + 'science' => 174, + 'news' => 16, + ]; + + return $categories[strtolower($category)] ?? 0; + } +} diff --git a/src/agent/src/Toolbox/Tool/Grafana.php b/src/agent/src/Toolbox/Tool/Grafana.php new file mode 100644 index 000000000..a205d0790 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Grafana.php @@ -0,0 +1,728 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('grafana_get_dashboards', 'Tool that gets Grafana dashboards')] +#[AsTool('grafana_get_datasources', 'Tool that gets Grafana datasources', method: 'getDatasources')] +#[AsTool('grafana_get_alerts', 'Tool that gets Grafana alerts', method: 'getAlerts')] +#[AsTool('grafana_get_users', 'Tool that gets Grafana users', method: 'getUsers')] +#[AsTool('grafana_get_teams', 'Tool that gets Grafana teams', method: 'getTeams')] +#[AsTool('grafana_get_annotations', 'Tool that gets Grafana annotations', method: 'getAnnotations')] +final readonly class Grafana +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $baseUrl, + private string $apiVersion = 'v1', + private array $options = [], + ) { + } + + /** + * Get Grafana dashboards. + * + * @param string $query Search query + * @param string $tag Tag filter + * @param string $type Dashboard type (dash-db, dash-folder) + * @param string $folderId Folder ID filter + * @param string $starred Starred filter (true, false) + * @param int $limit Number of dashboards to retrieve + * @param int $page Page number + * + * @return array, + * isStarred: bool, + * folderId: int, + * folderUid: string, + * folderTitle: string, + * folderUrl: string, + * uri: string, + * url: string, + * slug: string, + * version: int, + * hasAcl: bool, + * canEdit: bool, + * canAdmin: bool, + * canSave: bool, + * canStar: bool, + * canDelete: bool, + * created: string, + * updated: string, + * updatedBy: string, + * updatedByAvatar: string, + * version: int, + * hasAcl: bool, + * isFolder: bool, + * parentId: int, + * parentUid: string, + * parentTitle: string, + * parentUrl: string, + * }> + */ + public function __invoke( + string $query = '', + string $tag = '', + string $type = '', + string $folderId = '', + string $starred = '', + int $limit = 100, + int $page = 1, + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 1000), + 'page' => max($page, 1), + ]; + + if ($query) { + $params['query'] = $query; + } + if ($tag) { + $params['tag'] = $tag; + } + if ($type) { + $params['type'] = $type; + } + if ($folderId) { + $params['folderId'] = $folderId; + } + if ($starred) { + $params['starred'] = $starred; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/search", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($dashboard) => [ + 'id' => $dashboard['id'], + 'uid' => $dashboard['uid'], + 'title' => $dashboard['title'], + 'url' => $dashboard['url'], + 'type' => $dashboard['type'], + 'tags' => $dashboard['tags'] ?? [], + 'isStarred' => $dashboard['isStarred'] ?? false, + 'folderId' => $dashboard['folderId'] ?? 0, + 'folderUid' => $dashboard['folderUid'] ?? '', + 'folderTitle' => $dashboard['folderTitle'] ?? '', + 'folderUrl' => $dashboard['folderUrl'] ?? '', + 'uri' => $dashboard['uri'] ?? '', + 'slug' => $dashboard['slug'] ?? '', + 'version' => $dashboard['version'] ?? 1, + 'hasAcl' => $dashboard['hasAcl'] ?? false, + 'canEdit' => $dashboard['canEdit'] ?? false, + 'canAdmin' => $dashboard['canAdmin'] ?? false, + 'canSave' => $dashboard['canSave'] ?? false, + 'canStar' => $dashboard['canStar'] ?? false, + 'canDelete' => $dashboard['canDelete'] ?? false, + 'created' => $dashboard['created'] ?? '', + 'updated' => $dashboard['updated'] ?? '', + 'updatedBy' => $dashboard['updatedBy'] ?? '', + 'updatedByAvatar' => $dashboard['updatedByAvatar'] ?? '', + 'isFolder' => $dashboard['isFolder'] ?? false, + 'parentId' => $dashboard['parentId'] ?? 0, + 'parentUid' => $dashboard['parentUid'] ?? '', + 'parentTitle' => $dashboard['parentTitle'] ?? '', + 'parentUrl' => $dashboard['parentUrl'] ?? '', + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Grafana datasources. + * + * @return array, + * secureJsonData: array, + * secureJsonFields: array, + * readOnly: bool, + * version: int, + * }> + */ + public function getDatasources(): array + { + try { + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/datasources", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($datasource) => [ + 'id' => $datasource['id'], + 'uid' => $datasource['uid'], + 'orgId' => $datasource['orgId'], + 'name' => $datasource['name'], + 'type' => $datasource['type'], + 'typeName' => $datasource['typeName'], + 'typeLogoUrl' => $datasource['typeLogoUrl'], + 'access' => $datasource['access'], + 'url' => $datasource['url'], + 'password' => $datasource['password'] ?? '', + 'user' => $datasource['user'] ?? '', + 'database' => $datasource['database'] ?? '', + 'basicAuth' => $datasource['basicAuth'] ?? false, + 'basicAuthUser' => $datasource['basicAuthUser'] ?? '', + 'basicAuthPassword' => $datasource['basicAuthPassword'] ?? '', + 'withCredentials' => $datasource['withCredentials'] ?? false, + 'isDefault' => $datasource['isDefault'] ?? false, + 'jsonData' => $datasource['jsonData'] ?? [], + 'secureJsonData' => $datasource['secureJsonData'] ?? [], + 'secureJsonFields' => $datasource['secureJsonFields'] ?? [], + 'readOnly' => $datasource['readOnly'] ?? false, + 'version' => $datasource['version'] ?? 1, + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Grafana alerts. + * + * @param string $query Search query + * @param string $state Alert state (alerting, ok, no_data, pending) + * @param string $folderId Folder ID filter + * @param string $dashboardId Dashboard ID filter + * @param string $panelId Panel ID filter + * @param int $limit Number of alerts to retrieve + * @param int $page Page number + * + * @return array, + * executionError: string, + * evalData: array, + * evalDate: string, + * dashboardId: int, + * dashboardUid: string, + * dashboardSlug: string, + * dashboardTitle: string, + * panelId: int, + * panelTitle: string, + * orgId: int, + * ruleId: int, + * ruleName: string, + * ruleUrl: string, + * ruleState: string, + * ruleHealth: string, + * ruleType: string, + * ruleGroupName: string, + * ruleGroupIndex: int, + * ruleGroupUid: string, + * ruleGroupFolderUid: string, + * ruleGroupFolderTitle: string, + * ruleGroupFolderUrl: string, + * ruleGroupUrl: string, + * ruleUrl: string, + * annotations: array, + * labels: array, + * values: array, + * valueString: string, + * imageUrl: string, + * imagePublicUrl: string, + * imageOnEmbedUrl: string, + * imagePublicOnEmbedUrl: string, + * needsAck: bool, + * shouldRemoveImage: bool, + * isRegion: bool, + * url: string, + * }> + */ + public function getAlerts( + string $query = '', + string $state = '', + string $folderId = '', + string $dashboardId = '', + string $panelId = '', + int $limit = 100, + int $page = 1, + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 1000), + 'page' => max($page, 1), + ]; + + if ($query) { + $params['query'] = $query; + } + if ($state) { + $params['state'] = $state; + } + if ($folderId) { + $params['folderId'] = $folderId; + } + if ($dashboardId) { + $params['dashboardId'] = $dashboardId; + } + if ($panelId) { + $params['panelId'] = $panelId; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/alerts", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($alert) => [ + 'id' => $alert['id'], + 'uid' => $alert['uid'], + 'title' => $alert['title'], + 'state' => $alert['state'], + 'newStateDate' => $alert['newStateDate'], + 'prevStateDate' => $alert['prevStateDate'], + 'newState' => $alert['newState'], + 'prevState' => $alert['prevState'], + 'text' => $alert['text'], + 'data' => $alert['data'] ?? [], + 'executionError' => $alert['executionError'] ?? '', + 'evalData' => $alert['evalData'] ?? [], + 'evalDate' => $alert['evalDate'] ?? '', + 'dashboardId' => $alert['dashboardId'], + 'dashboardUid' => $alert['dashboardUid'], + 'dashboardSlug' => $alert['dashboardSlug'], + 'dashboardTitle' => $alert['dashboardTitle'], + 'panelId' => $alert['panelId'], + 'panelTitle' => $alert['panelTitle'], + 'orgId' => $alert['orgId'], + 'ruleId' => $alert['ruleId'], + 'ruleName' => $alert['ruleName'], + 'ruleUrl' => $alert['ruleUrl'], + 'ruleState' => $alert['ruleState'], + 'ruleHealth' => $alert['ruleHealth'], + 'ruleType' => $alert['ruleType'], + 'ruleGroupName' => $alert['ruleGroupName'], + 'ruleGroupIndex' => $alert['ruleGroupIndex'], + 'ruleGroupUid' => $alert['ruleGroupUid'], + 'ruleGroupFolderUid' => $alert['ruleGroupFolderUid'], + 'ruleGroupFolderTitle' => $alert['ruleGroupFolderTitle'], + 'ruleGroupFolderUrl' => $alert['ruleGroupFolderUrl'], + 'ruleGroupUrl' => $alert['ruleGroupUrl'], + 'annotations' => $alert['annotations'] ?? [], + 'labels' => $alert['labels'] ?? [], + 'values' => $alert['values'] ?? [], + 'valueString' => $alert['valueString'] ?? '', + 'imageUrl' => $alert['imageUrl'] ?? '', + 'imagePublicUrl' => $alert['imagePublicUrl'] ?? '', + 'imageOnEmbedUrl' => $alert['imageOnEmbedUrl'] ?? '', + 'imagePublicOnEmbedUrl' => $alert['imagePublicOnEmbedUrl'] ?? '', + 'needsAck' => $alert['needsAck'] ?? false, + 'shouldRemoveImage' => $alert['shouldRemoveImage'] ?? false, + 'isRegion' => $alert['isRegion'] ?? false, + 'url' => $alert['url'] ?? '', + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Grafana users. + * + * @param int $perPage Number of users per page + * @param int $page Page number + * @param string $query Search query + * + * @return array, + * isDisabled: bool, + * isExternal: bool, + * helpFlags1: int, + * hasSeenAnnouncement: bool, + * teams: array, + * hasAcl: bool, + * url: string, + * slug: string, + * created: string, + * updated: string, + * }>, + * orgs: array, + * }> + */ + public function getUsers( + int $perPage = 50, + int $page = 1, + string $query = '', + ): array { + try { + $params = [ + 'perpage' => min(max($perPage, 1), 1000), + 'page' => max($page, 1), + ]; + + if ($query) { + $params['query'] = $query; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/users", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($user) => [ + 'id' => $user['id'], + 'email' => $user['email'], + 'name' => $user['name'], + 'login' => $user['login'], + 'theme' => $user['theme'] ?? '', + 'orgId' => $user['orgId'], + 'isGrafanaAdmin' => $user['isGrafanaAdmin'] ?? false, + 'isDisabled' => $user['isDisabled'] ?? false, + 'isExternal' => $user['isExternal'] ?? false, + 'updatedAt' => $user['updatedAt'], + 'createdAt' => $user['createdAt'], + 'lastSeenAt' => $user['lastSeenAt'] ?? '', + 'lastSeenAtAge' => $user['lastSeenAtAge'] ?? '', + 'authLabels' => $user['authLabels'] ?? [], + 'helpFlags1' => $user['helpFlags1'] ?? 0, + 'hasSeenAnnouncement' => $user['hasSeenAnnouncement'] ?? false, + 'teams' => array_map(fn ($team) => [ + 'id' => $team['id'], + 'orgId' => $team['orgId'], + 'name' => $team['name'], + 'email' => $team['email'], + 'avatarUrl' => $team['avatarUrl'], + 'memberCount' => $team['memberCount'], + 'permission' => $team['permission'], + 'accessControl' => $team['accessControl'] ?? [], + 'hasAcl' => $team['hasAcl'] ?? false, + 'url' => $team['url'], + 'slug' => $team['slug'], + 'created' => $team['created'], + 'updated' => $team['updated'], + ], $user['teams'] ?? []), + 'orgs' => array_map(fn ($org) => [ + 'orgId' => $org['orgId'], + 'name' => $org['name'], + 'role' => $org['role'], + ], $user['orgs'] ?? []), + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Grafana teams. + * + * @param int $perPage Number of teams per page + * @param int $page Page number + * @param string $query Search query + * + * @return array, + * hasAcl: bool, + * url: string, + * slug: string, + * created: string, + * updated: string, + * }> + */ + public function getTeams( + int $perPage = 50, + int $page = 1, + string $query = '', + ): array { + try { + $params = [ + 'perpage' => min(max($perPage, 1), 1000), + 'page' => max($page, 1), + ]; + + if ($query) { + $params['query'] = $query; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/teams/search", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($team) => [ + 'id' => $team['id'], + 'orgId' => $team['orgId'], + 'name' => $team['name'], + 'email' => $team['email'], + 'avatarUrl' => $team['avatarUrl'], + 'memberCount' => $team['memberCount'], + 'permission' => $team['permission'], + 'accessControl' => $team['accessControl'] ?? [], + 'hasAcl' => $team['hasAcl'] ?? false, + 'url' => $team['url'], + 'slug' => $team['slug'], + 'created' => $team['created'], + 'updated' => $team['updated'], + ], $data['teams'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Grafana annotations. + * + * @param string $from Start time (Unix timestamp) + * @param string $to End time (Unix timestamp) + * @param int $limit Number of annotations to retrieve + * @param string $alertId Alert ID filter + * @param string $dashboardId Dashboard ID filter + * @param string $panelId Panel ID filter + * @param string $userId User ID filter + * @param string $type Annotation type (alert, annotation) + * @param string $tags Comma-separated tags + * + * @return array, + * login: string, + * email: string, + * avatarUrl: string, + * data: array, + * regionId: int, + * type: string, + * title: string, + * description: string, + * created: int, + * updated: int, + * updatedBy: int, + * updatedByLogin: string, + * updatedByEmail: string, + * updatedByAvatar: string, + * isRegion: bool, + * url: string, + * }> + */ + public function getAnnotations( + string $from, + string $to, + int $limit = 100, + string $alertId = '', + string $dashboardId = '', + string $panelId = '', + string $userId = '', + string $type = '', + string $tags = '', + ): array { + try { + $params = [ + 'from' => $from, + 'to' => $to, + 'limit' => min(max($limit, 1), 1000), + ]; + + if ($alertId) { + $params['alertId'] = $alertId; + } + if ($dashboardId) { + $params['dashboardId'] = $dashboardId; + } + if ($panelId) { + $params['panelId'] = $panelId; + } + if ($userId) { + $params['userId'] = $userId; + } + if ($type) { + $params['type'] = $type; + } + if ($tags) { + $params['tags'] = $tags; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/annotations", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($annotation) => [ + 'id' => $annotation['id'], + 'alertId' => $annotation['alertId'] ?? 0, + 'alertName' => $annotation['alertName'] ?? '', + 'dashboardId' => $annotation['dashboardId'] ?? 0, + 'dashboardUid' => $annotation['dashboardUid'] ?? '', + 'dashboardSlug' => $annotation['dashboardSlug'] ?? '', + 'dashboardTitle' => $annotation['dashboardTitle'] ?? '', + 'panelId' => $annotation['panelId'] ?? 0, + 'panelTitle' => $annotation['panelTitle'] ?? '', + 'userId' => $annotation['userId'] ?? 0, + 'userName' => $annotation['userName'] ?? '', + 'newState' => $annotation['newState'] ?? '', + 'prevState' => $annotation['prevState'] ?? '', + 'time' => $annotation['time'], + 'timeEnd' => $annotation['timeEnd'] ?? 0, + 'text' => $annotation['text'], + 'tags' => $annotation['tags'] ?? [], + 'login' => $annotation['login'] ?? '', + 'email' => $annotation['email'] ?? '', + 'avatarUrl' => $annotation['avatarUrl'] ?? '', + 'data' => $annotation['data'] ?? [], + 'regionId' => $annotation['regionId'] ?? 0, + 'type' => $annotation['type'] ?? 'annotation', + 'title' => $annotation['title'] ?? '', + 'description' => $annotation['description'] ?? '', + 'created' => $annotation['created'] ?? 0, + 'updated' => $annotation['updated'] ?? 0, + 'updatedBy' => $annotation['updatedBy'] ?? 0, + 'updatedByLogin' => $annotation['updatedByLogin'] ?? '', + 'updatedByEmail' => $annotation['updatedByEmail'] ?? '', + 'updatedByAvatar' => $annotation['updatedByAvatar'] ?? '', + 'isRegion' => $annotation['isRegion'] ?? false, + 'url' => $annotation['url'] ?? '', + ], $data); + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/GraphQL.php b/src/agent/src/Toolbox/Tool/GraphQL.php new file mode 100644 index 000000000..7679427be --- /dev/null +++ b/src/agent/src/Toolbox/Tool/GraphQL.php @@ -0,0 +1,456 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('graphql_query', 'Tool that executes GraphQL queries')] +#[AsTool('graphql_mutation', 'Tool that executes GraphQL mutations', method: 'mutation')] +#[AsTool('graphql_introspect', 'Tool that introspects GraphQL schema', method: 'introspect')] +#[AsTool('graphql_validate', 'Tool that validates GraphQL queries', method: 'validate')] +final readonly class GraphQL +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $endpoint, + private array $headers = [], + private array $options = [], + ) { + } + + /** + * Execute GraphQL query. + * + * @param string $query GraphQL query string + * @param array $variables Query variables + * @param string $operationName Operation name + * + * @return array{ + * data: array|null, + * errors: array, + * path: array, + * extensions: array, + * }>, + * extensions: array, + * } + */ + public function __invoke( + string $query, + array $variables = [], + string $operationName = '', + ): array { + try { + $body = [ + 'query' => $query, + ]; + + if (!empty($variables)) { + $body['variables'] = $variables; + } + + if ($operationName) { + $body['operationName'] = $operationName; + } + + $response = $this->httpClient->request('POST', $this->endpoint, [ + 'headers' => array_merge([ + 'Content-Type' => 'application/json', + ], $this->headers), + 'json' => $body, + ]); + + $data = $response->toArray(); + + return [ + 'data' => $data['data'] ?? null, + 'errors' => array_map(fn ($error) => [ + 'message' => $error['message'], + 'locations' => array_map(fn ($location) => [ + 'line' => $location['line'], + 'column' => $location['column'], + ], $error['locations'] ?? []), + 'path' => $error['path'] ?? [], + 'extensions' => $error['extensions'] ?? [], + ], $data['errors'] ?? []), + 'extensions' => $data['extensions'] ?? [], + ]; + } catch (\Exception $e) { + return [ + 'data' => null, + 'errors' => [ + [ + 'message' => $e->getMessage(), + 'locations' => [], + 'path' => [], + 'extensions' => [], + ], + ], + 'extensions' => [], + ]; + } + } + + /** + * Execute GraphQL mutation. + * + * @param string $mutation GraphQL mutation string + * @param array $variables Mutation variables + * @param string $operationName Operation name + * + * @return array{ + * data: array|null, + * errors: array, + * path: array, + * extensions: array, + * }>, + * extensions: array, + * } + */ + public function mutation( + string $mutation, + array $variables = [], + string $operationName = '', + ): array { + // Mutations are essentially the same as queries in GraphQL + return $this->__invoke($mutation, $variables, $operationName); + } + + /** + * Introspect GraphQL schema. + * + * @param string $queryType Type to introspect (query, mutation, subscription, or specific type) + * + * @return array{ + * data: array|null, + * errors: array, + * path: array, + * extensions: array, + * }>, + * schema: array{ + * queryType: array{ + * name: string, + * fields: array, + * args: array, + * }>, + * }>, + * }, + * mutationType: array{ + * name: string, + * fields: array, + * args: array, + * }>, + * }>, + * }|null, + * subscriptionType: array{ + * name: string, + * fields: array, + * args: array, + * }>, + * }>, + * }|null, + * types: array, + * }>, + * }>, + * }, + * } + */ + public function introspect(string $queryType = 'full'): array + { + $introspectionQuery = match ($queryType) { + 'query' => ' + query IntrospectionQuery { + __schema { + queryType { + name + fields { + name + type { + name + kind + } + args { + name + type { + name + kind + } + } + } + } + } + } + ', + 'mutation' => ' + query IntrospectionQuery { + __schema { + mutationType { + name + fields { + name + type { + name + kind + } + args { + name + type { + name + kind + } + } + } + } + } + } + ', + 'subscription' => ' + query IntrospectionQuery { + __schema { + subscriptionType { + name + fields { + name + type { + name + kind + } + args { + name + type { + name + kind + } + } + } + } + } + } + ', + default => ' + query IntrospectionQuery { + __schema { + queryType { + name + fields { + name + type { + name + kind + } + args { + name + type { + name + kind + } + } + } + } + mutationType { + name + fields { + name + type { + name + kind + } + args { + name + type { + name + kind + } + } + } + } + subscriptionType { + name + fields { + name + type { + name + kind + } + args { + name + type { + name + kind + } + } + } + } + types { + name + kind + fields { + name + type { + name + kind + } + } + } + } + } + ', + }; + + $result = $this->__invoke($introspectionQuery); + + // Parse schema information + $schema = [ + 'queryType' => [ + 'name' => '', + 'fields' => [], + ], + 'mutationType' => null, + 'subscriptionType' => null, + 'types' => [], + ]; + + if ($result['data'] && isset($result['data']['__schema'])) { + $schemaData = $result['data']['__schema']; + + if (isset($schemaData['queryType'])) { + $schema['queryType'] = [ + 'name' => $schemaData['queryType']['name'], + 'fields' => array_map(fn ($field) => [ + 'name' => $field['name'], + 'type' => $field['type'], + 'args' => array_map(fn ($arg) => [ + 'name' => $arg['name'], + 'type' => $arg['type'], + ], $field['args'] ?? []), + ], $schemaData['queryType']['fields'] ?? []), + ]; + } + + if (isset($schemaData['mutationType'])) { + $schema['mutationType'] = [ + 'name' => $schemaData['mutationType']['name'], + 'fields' => array_map(fn ($field) => [ + 'name' => $field['name'], + 'type' => $field['type'], + 'args' => array_map(fn ($arg) => [ + 'name' => $arg['name'], + 'type' => $arg['type'], + ], $field['args'] ?? []), + ], $schemaData['mutationType']['fields'] ?? []), + ]; + } + + if (isset($schemaData['subscriptionType'])) { + $schema['subscriptionType'] = [ + 'name' => $schemaData['subscriptionType']['name'], + 'fields' => array_map(fn ($field) => [ + 'name' => $field['name'], + 'type' => $field['type'], + 'args' => array_map(fn ($arg) => [ + 'name' => $arg['name'], + 'type' => $arg['type'], + ], $field['args'] ?? []), + ], $schemaData['subscriptionType']['fields'] ?? []), + ]; + } + + if (isset($schemaData['types'])) { + $schema['types'] = array_map(fn ($type) => [ + 'name' => $type['name'], + 'kind' => $type['kind'], + 'fields' => array_map(fn ($field) => [ + 'name' => $field['name'], + 'type' => $field['type'], + ], $type['fields'] ?? []), + ], $schemaData['types']); + } + } + + return [ + 'data' => $result['data'], + 'errors' => $result['errors'], + 'schema' => $schema, + ]; + } + + /** + * Validate GraphQL query. + * + * @param string $query GraphQL query to validate + * + * @return array{ + * valid: bool, + * errors: array, + * path: array, + * }>, + * } + */ + public function validate(string $query): array + { + // Use a simple introspection query to test if the query is valid + $testQuery = 'query { __typename }'; + $result = $this->__invoke($query); + + return [ + 'valid' => empty($result['errors']), + 'errors' => array_map(fn ($error) => [ + 'message' => $error['message'], + 'locations' => $error['locations'], + 'path' => $error['path'], + ], $result['errors']), + ]; + } +} diff --git a/src/agent/src/Toolbox/Tool/Heroku.php b/src/agent/src/Toolbox/Tool/Heroku.php new file mode 100644 index 000000000..5eb5d0505 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Heroku.php @@ -0,0 +1,514 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('heroku_get_apps', 'Tool that gets Heroku apps')] +#[AsTool('heroku_create_app', 'Tool that creates Heroku apps', method: 'createApp')] +#[AsTool('heroku_get_dynos', 'Tool that gets Heroku dynos', method: 'getDynos')] +#[AsTool('heroku_scale_dynos', 'Tool that scales Heroku dynos', method: 'scaleDynos')] +#[AsTool('heroku_get_addons', 'Tool that gets Heroku addons', method: 'getAddons')] +#[AsTool('heroku_get_logs', 'Tool that gets Heroku logs', method: 'getLogs')] +final readonly class Heroku +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $apiVersion = '3', + private array $options = [], + ) { + } + + /** + * Get Heroku apps. + * + * @param int $limit Number of apps to retrieve + * + * @return array + */ + public function __invoke(int $limit = 50): array + { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), + ]; + + $response = $this->httpClient->request('GET', 'https://api.heroku.com/apps', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Accept' => 'application/vnd.heroku+json; version='.$this->apiVersion, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return array_map(fn ($app) => [ + 'id' => $app['id'], + 'name' => $app['name'], + 'stack' => [ + 'id' => $app['stack']['id'], + 'name' => $app['stack']['name'], + ], + 'region' => [ + 'id' => $app['region']['id'], + 'name' => $app['region']['name'], + ], + 'space' => $app['space'] ? [ + 'id' => $app['space']['id'], + 'name' => $app['space']['name'], + ] : null, + 'internal_routing' => $app['internal_routing'] ?? false, + 'git_url' => $app['git_url'], + 'web_url' => $app['web_url'], + 'owner' => [ + 'id' => $app['owner']['id'], + 'email' => $app['owner']['email'], + ], + 'organization' => $app['organization'] ? [ + 'id' => $app['organization']['id'], + 'name' => $app['organization']['name'], + ] : null, + 'team' => $app['team'] ? [ + 'id' => $app['team']['id'], + 'name' => $app['team']['name'], + ] : null, + 'acm' => $app['acm'] ?? false, + 'archived_at' => $app['archived_at'], + 'buildpack_provided_description' => $app['buildpack_provided_description'], + 'build_stack' => [ + 'id' => $app['build_stack']['id'], + 'name' => $app['build_stack']['name'], + ], + 'created_at' => $app['created_at'], + 'updated_at' => $app['updated_at'], + 'released_at' => $app['released_at'], + 'repo_size' => $app['repo_size'], + 'slug_size' => $app['slug_size'], + 'dyno_size' => $app['dyno_size'] ?? 0, + 'repo_migrated_at' => $app['repo_migrated_at'], + 'stack_migrated_at' => $app['stack_migrated_at'], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a Heroku app. + * + * @param string $name App name + * @param string $region Region (us, eu) + * @param string $stack Stack (heroku-18, heroku-20, heroku-22) + * @param string $spaceId Space ID (for private spaces) + * @param string $organizationId Organization ID + * @param string $teamId Team ID + * + * @return array{ + * id: string, + * name: string, + * stack: array{id: string, name: string}, + * region: array{id: string, name: string}, + * git_url: string, + * web_url: string, + * owner: array{id: string, email: string}, + * created_at: string, + * updated_at: string, + * released_at: string, + * }|string + */ + public function createApp( + string $name, + string $region = 'us', + string $stack = 'heroku-22', + string $spaceId = '', + string $organizationId = '', + string $teamId = '', + ): array|string { + try { + $payload = [ + 'name' => $name, + 'region' => $region, + 'stack' => $stack, + ]; + + if ($spaceId) { + $payload['space'] = ['id' => $spaceId]; + } + if ($organizationId) { + $payload['organization'] = ['id' => $organizationId]; + } + if ($teamId) { + $payload['team'] = ['id' => $teamId]; + } + + $response = $this->httpClient->request('POST', 'https://api.heroku.com/apps', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Accept' => 'application/vnd.heroku+json; version='.$this->apiVersion, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (false === isset($data['id'])) { + return 'Error creating app: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'stack' => [ + 'id' => $data['stack']['id'], + 'name' => $data['stack']['name'], + ], + 'region' => [ + 'id' => $data['region']['id'], + 'name' => $data['region']['name'], + ], + 'git_url' => $data['git_url'], + 'web_url' => $data['web_url'], + 'owner' => [ + 'id' => $data['owner']['id'], + 'email' => $data['owner']['email'], + ], + 'created_at' => $data['created_at'], + 'updated_at' => $data['updated_at'], + 'released_at' => $data['released_at'], + ]; + } catch (\Exception $e) { + return 'Error creating app: '.$e->getMessage(); + } + } + + /** + * Get Heroku dynos. + * + * @param string $appId App ID + * @param int $limit Number of dynos to retrieve + * + * @return array + */ + public function getDynos(string $appId, int $limit = 50): array + { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), + ]; + + $response = $this->httpClient->request('GET', "https://api.heroku.com/apps/{$appId}/dynos", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Accept' => 'application/vnd.heroku+json; version='.$this->apiVersion, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return array_map(fn ($dyno) => [ + 'id' => $dyno['id'], + 'app' => [ + 'id' => $dyno['app']['id'], + 'name' => $dyno['app']['name'], + ], + 'attach_url' => $dyno['attach_url'], + 'command' => $dyno['command'], + 'created_at' => $dyno['created_at'], + 'name' => $dyno['name'], + 'release' => [ + 'id' => $dyno['release']['id'], + 'version' => $dyno['release']['version'], + ], + 'size' => $dyno['size'], + 'state' => $dyno['state'], + 'type' => $dyno['type'], + 'updated_at' => $dyno['updated_at'], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Scale Heroku dynos. + * + * @param string $appId App ID + * @param string $type Dyno type (web, worker, etc.) + * @param int $quantity Number of dynos + * @param string $size Dyno size (basic, standard-1x, standard-2x, etc.) + * + * @return array{ + * id: string, + * app: array{id: string, name: string}, + * command: string, + * created_at: string, + * name: string, + * release: array{id: string, version: int}, + * size: string, + * state: string, + * type: string, + * updated_at: string, + * }|string + */ + public function scaleDynos( + string $appId, + string $type, + int $quantity, + string $size = 'basic', + ): array|string { + try { + $payload = [ + 'updates' => [ + [ + 'type' => $type, + 'quantity' => $quantity, + 'size' => $size, + ], + ], + ]; + + $response = $this->httpClient->request('PATCH', "https://api.heroku.com/apps/{$appId}/formation", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Accept' => 'application/vnd.heroku+json; version='.$this->apiVersion, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (false === isset($data[0]['id'])) { + return 'Error scaling dynos: '.($data['message'] ?? 'Unknown error'); + } + + $dyno = $data[0]; + + return [ + 'id' => $dyno['id'], + 'app' => [ + 'id' => $dyno['app']['id'], + 'name' => $dyno['app']['name'], + ], + 'command' => $dyno['command'], + 'created_at' => $dyno['created_at'], + 'name' => $dyno['name'], + 'release' => [ + 'id' => $dyno['release']['id'], + 'version' => $dyno['release']['version'], + ], + 'size' => $dyno['size'], + 'state' => $dyno['state'], + 'type' => $dyno['type'], + 'updated_at' => $dyno['updated_at'], + ]; + } catch (\Exception $e) { + return 'Error scaling dynos: '.$e->getMessage(); + } + } + + /** + * Get Heroku addons. + * + * @param string $appId App ID + * @param int $limit Number of addons to retrieve + * + * @return array, + * created_at: string, + * name: string, + * plan: array{ + * id: string, + * name: string, + * price: array{cents: int, unit: string}, + * state: string, + * }, + * provider_id: string|null, + * state: string, + * updated_at: string, + * web_url: string|null, + * }> + */ + public function getAddons(string $appId, int $limit = 50): array + { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), + ]; + + $response = $this->httpClient->request('GET', "https://api.heroku.com/apps/{$appId}/addons", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Accept' => 'application/vnd.heroku+json; version='.$this->apiVersion, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return array_map(fn ($addon) => [ + 'id' => $addon['id'], + 'app' => [ + 'id' => $addon['app']['id'], + 'name' => $addon['app']['name'], + ], + 'addon_service' => [ + 'id' => $addon['addon_service']['id'], + 'name' => $addon['addon_service']['name'], + 'human_name' => $addon['addon_service']['human_name'], + 'description' => $addon['addon_service']['description'], + 'state' => $addon['addon_service']['state'], + 'price' => [ + 'cents' => $addon['addon_service']['price']['cents'], + 'unit' => $addon['addon_service']['price']['unit'], + ], + ], + 'config_vars' => $addon['config_vars'] ?? [], + 'created_at' => $addon['created_at'], + 'name' => $addon['name'], + 'plan' => [ + 'id' => $addon['plan']['id'], + 'name' => $addon['plan']['name'], + 'price' => [ + 'cents' => $addon['plan']['price']['cents'], + 'unit' => $addon['plan']['price']['unit'], + ], + 'state' => $addon['plan']['state'], + ], + 'provider_id' => $addon['provider_id'], + 'state' => $addon['state'], + 'updated_at' => $addon['updated_at'], + 'web_url' => $addon['web_url'], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Heroku logs. + * + * @param string $appId App ID + * @param int $lines Number of log lines to retrieve + * @param string $source Log source (app, heroku, etc.) + * @param string $dyno Dyno name + * @param string $tail Whether to tail logs (true/false) + * + * @return array{ + * logs: string, + * truncated: bool, + * }|string + */ + public function getLogs( + string $appId, + int $lines = 100, + string $source = '', + string $dyno = '', + string $tail = 'false', + ): array|string { + try { + $params = [ + 'lines' => min(max($lines, 1), 1500), + 'tail' => $tail, + ]; + + if ($source) { + $params['source'] = $source; + } + if ($dyno) { + $params['dyno'] = $dyno; + } + + $response = $this->httpClient->request('GET', "https://api.heroku.com/apps/{$appId}/log-sessions", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Accept' => 'application/vnd.heroku+json; version='.$this->apiVersion, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['logplex_url'])) { + // Get logs from logplex URL + $logResponse = $this->httpClient->request('GET', $data['logplex_url']); + $logs = $logResponse->getContent(); + + return [ + 'logs' => $logs, + 'truncated' => false, + ]; + } + + return 'Error getting logs: '.($data['message'] ?? 'Unknown error'); + } catch (\Exception $e) { + return 'Error getting logs: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/HttpRequests.php b/src/agent/src/Toolbox/Tool/HttpRequests.php new file mode 100644 index 000000000..9cce5840b --- /dev/null +++ b/src/agent/src/Toolbox/Tool/HttpRequests.php @@ -0,0 +1,184 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('requests_get', 'Tool for making GET requests to API endpoints')] +#[AsTool('requests_post', 'Tool for making POST requests to API endpoints', method: 'post')] +#[AsTool('requests_put', 'Tool for making PUT requests to API endpoints', method: 'put')] +#[AsTool('requests_patch', 'Tool for making PATCH requests to API endpoints', method: 'patch')] +#[AsTool('requests_delete', 'Tool for making DELETE requests to API endpoints', method: 'delete')] +final readonly class HttpRequests +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private bool $allowDangerousRequests = false, + private array $options = [], + ) { + if (!$this->allowDangerousRequests) { + throw new \InvalidArgumentException('You must set allowDangerousRequests to true to use this tool. Requests can be dangerous and can lead to security vulnerabilities. For example, users can ask a server to make a request to an internal server. It\'s recommended to use requests through a proxy server and avoid accepting inputs from untrusted sources without proper sandboxing.'); + } + } + + /** + * Make a GET request to a URL. + * + * @param string $url The URL to make a GET request to + * + * @return array{status_code: int, headers: array>, content: string} + */ + public function __invoke(string $url): array + { + try { + $response = $this->httpClient->request('GET', $url, $this->options); + + return [ + 'status_code' => $response->getStatusCode(), + 'headers' => $response->getHeaders(), + 'content' => $response->getContent(), + ]; + } catch (\Exception $e) { + return [ + 'status_code' => 0, + 'headers' => [], + 'content' => 'Error: '.$e->getMessage(), + ]; + } + } + + /** + * Make a POST request to a URL. + * + * @param string $url The URL to make a POST request to + * @param array $data The data to send in the POST request + * + * @return array{status_code: int, headers: array>, content: string} + */ + public function post(string $url, array $data = []): array + { + try { + $options = array_merge($this->options, [ + 'json' => $data, + ]); + + $response = $this->httpClient->request('POST', $url, $options); + + return [ + 'status_code' => $response->getStatusCode(), + 'headers' => $response->getHeaders(), + 'content' => $response->getContent(), + ]; + } catch (\Exception $e) { + return [ + 'status_code' => 0, + 'headers' => [], + 'content' => 'Error: '.$e->getMessage(), + ]; + } + } + + /** + * Make a PUT request to a URL. + * + * @param string $url The URL to make a PUT request to + * @param array $data The data to send in the PUT request + * + * @return array{status_code: int, headers: array>, content: string} + */ + public function put(string $url, array $data = []): array + { + try { + $options = array_merge($this->options, [ + 'json' => $data, + ]); + + $response = $this->httpClient->request('PUT', $url, $options); + + return [ + 'status_code' => $response->getStatusCode(), + 'headers' => $response->getHeaders(), + 'content' => $response->getContent(), + ]; + } catch (\Exception $e) { + return [ + 'status_code' => 0, + 'headers' => [], + 'content' => 'Error: '.$e->getMessage(), + ]; + } + } + + /** + * Make a PATCH request to a URL. + * + * @param string $url The URL to make a PATCH request to + * @param array $data The data to send in the PATCH request + * + * @return array{status_code: int, headers: array>, content: string} + */ + public function patch(string $url, array $data = []): array + { + try { + $options = array_merge($this->options, [ + 'json' => $data, + ]); + + $response = $this->httpClient->request('PATCH', $url, $options); + + return [ + 'status_code' => $response->getStatusCode(), + 'headers' => $response->getHeaders(), + 'content' => $response->getContent(), + ]; + } catch (\Exception $e) { + return [ + 'status_code' => 0, + 'headers' => [], + 'content' => 'Error: '.$e->getMessage(), + ]; + } + } + + /** + * Make a DELETE request to a URL. + * + * @param string $url The URL to make a DELETE request to + * + * @return array{status_code: int, headers: array>, content: string} + */ + public function delete(string $url): array + { + try { + $response = $this->httpClient->request('DELETE', $url, $this->options); + + return [ + 'status_code' => $response->getStatusCode(), + 'headers' => $response->getHeaders(), + 'content' => $response->getContent(), + ]; + } catch (\Exception $e) { + return [ + 'status_code' => 0, + 'headers' => [], + 'content' => 'Error: '.$e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/HubSpot.php b/src/agent/src/Toolbox/Tool/HubSpot.php new file mode 100644 index 000000000..a79bab17f --- /dev/null +++ b/src/agent/src/Toolbox/Tool/HubSpot.php @@ -0,0 +1,590 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('hubspot_create_contact', 'Tool that creates HubSpot contacts')] +#[AsTool('hubspot_get_contact', 'Tool that gets HubSpot contacts', method: 'getContact')] +#[AsTool('hubspot_update_contact', 'Tool that updates HubSpot contacts', method: 'updateContact')] +#[AsTool('hubspot_create_company', 'Tool that creates HubSpot companies', method: 'createCompany')] +#[AsTool('hubspot_create_deal', 'Tool that creates HubSpot deals', method: 'createDeal')] +#[AsTool('hubspot_search_objects', 'Tool that searches HubSpot objects', method: 'searchObjects')] +#[AsTool('hubspot_create_ticket', 'Tool that creates HubSpot tickets', method: 'createTicket')] +final readonly class HubSpot +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private string $apiVersion = 'v3', + private array $options = [], + ) { + } + + /** + * Create a HubSpot contact. + * + * @param string $email Contact email address + * @param string $firstName Contact first name + * @param string $lastName Contact last name + * @param string $phone Contact phone number + * @param string $company Contact company + * @param string $jobTitle Contact job title + * @param string $website Contact website + * @param string $city Contact city + * @param string $state Contact state + * @param string $country Contact country + * @param string $zipCode Contact zip code + * @param array $properties Additional contact properties + * + * @return array{ + * id: string, + * properties: array, + * createdAt: string, + * updatedAt: string, + * archived: bool, + * }|string + */ + public function __invoke( + string $email, + string $firstName = '', + string $lastName = '', + string $phone = '', + string $company = '', + string $jobTitle = '', + string $website = '', + string $city = '', + string $state = '', + string $country = '', + string $zipCode = '', + array $properties = [], + ): array|string { + try { + $contactProperties = array_merge($properties, [ + 'email' => $email, + 'firstname' => $firstName, + 'lastname' => $lastName, + 'phone' => $phone, + 'company' => $company, + 'jobtitle' => $jobTitle, + 'website' => $website, + 'city' => $city, + 'state' => $state, + 'country' => $country, + 'zip' => $zipCode, + ]); + + // Remove empty values + $contactProperties = array_filter($contactProperties, fn ($value) => '' !== $value); + + $payload = [ + 'properties' => $contactProperties, + ]; + + $response = $this->httpClient->request('POST', "https://api.hubapi.com/{$this->apiVersion}/objects/contacts", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && 'error' === $data['status']) { + return 'Error creating contact: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'properties' => $data['properties'], + 'createdAt' => $data['createdAt'], + 'updatedAt' => $data['updatedAt'], + 'archived' => $data['archived'] ?? false, + ]; + } catch (\Exception $e) { + return 'Error creating contact: '.$e->getMessage(); + } + } + + /** + * Get a HubSpot contact. + * + * @param string $contactId Contact ID + * @param array $properties Properties to retrieve + * + * @return array{ + * id: string, + * properties: array, + * createdAt: string, + * updatedAt: string, + * archived: bool, + * }|string + */ + public function getContact( + string $contactId, + array $properties = [], + ): array|string { + try { + $params = []; + if (!empty($properties)) { + $params['properties'] = implode(',', $properties); + } + + $response = $this->httpClient->request('GET', "https://api.hubapi.com/{$this->apiVersion}/objects/contacts/{$contactId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'query' => $params, + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && 'error' === $data['status']) { + return 'Error getting contact: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'properties' => $data['properties'], + 'createdAt' => $data['createdAt'], + 'updatedAt' => $data['updatedAt'], + 'archived' => $data['archived'] ?? false, + ]; + } catch (\Exception $e) { + return 'Error getting contact: '.$e->getMessage(); + } + } + + /** + * Update a HubSpot contact. + * + * @param string $contactId Contact ID + * @param array $properties Properties to update + * + * @return array{ + * id: string, + * properties: array, + * createdAt: string, + * updatedAt: string, + * archived: bool, + * }|string + */ + public function updateContact( + string $contactId, + array $properties, + ): array|string { + try { + $payload = [ + 'properties' => $properties, + ]; + + $response = $this->httpClient->request('PATCH', "https://api.hubapi.com/{$this->apiVersion}/objects/contacts/{$contactId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && 'error' === $data['status']) { + return 'Error updating contact: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'properties' => $data['properties'], + 'createdAt' => $data['createdAt'], + 'updatedAt' => $data['updatedAt'], + 'archived' => $data['archived'] ?? false, + ]; + } catch (\Exception $e) { + return 'Error updating contact: '.$e->getMessage(); + } + } + + /** + * Create a HubSpot company. + * + * @param string $name Company name + * @param string $domain Company domain + * @param string $industry Company industry + * @param string $phone Company phone + * @param string $city Company city + * @param string $state Company state + * @param string $country Company country + * @param string $zipCode Company zip code + * @param int $numberOfEmployees Number of employees + * @param string $annualRevenue Annual revenue + * @param array $properties Additional company properties + * + * @return array{ + * id: string, + * properties: array, + * createdAt: string, + * updatedAt: string, + * archived: bool, + * }|string + */ + public function createCompany( + string $name, + string $domain = '', + string $industry = '', + string $phone = '', + string $city = '', + string $state = '', + string $country = '', + string $zipCode = '', + int $numberOfEmployees = 0, + string $annualRevenue = '', + array $properties = [], + ): array|string { + try { + $companyProperties = array_merge($properties, [ + 'name' => $name, + 'domain' => $domain, + 'industry' => $industry, + 'phone' => $phone, + 'city' => $city, + 'state' => $state, + 'country' => $country, + 'zip' => $zipCode, + 'numberofemployees' => $numberOfEmployees > 0 ? $numberOfEmployees : null, + 'annualrevenue' => $annualRevenue, + ]); + + // Remove empty values + $companyProperties = array_filter($companyProperties, fn ($value) => '' !== $value && null !== $value); + + $payload = [ + 'properties' => $companyProperties, + ]; + + $response = $this->httpClient->request('POST', "https://api.hubapi.com/{$this->apiVersion}/objects/companies", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && 'error' === $data['status']) { + return 'Error creating company: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'properties' => $data['properties'], + 'createdAt' => $data['createdAt'], + 'updatedAt' => $data['updatedAt'], + 'archived' => $data['archived'] ?? false, + ]; + } catch (\Exception $e) { + return 'Error creating company: '.$e->getMessage(); + } + } + + /** + * Create a HubSpot deal. + * + * @param string $dealName Deal name + * @param string $dealStage Deal stage + * @param string $closeDate Deal close date + * @param string $amount Deal amount + * @param string $dealType Deal type + * @param string $pipeline Deal pipeline + * @param string $contactId Associated contact ID + * @param string $companyId Associated company ID + * @param string $description Deal description + * @param array $properties Additional deal properties + * + * @return array{ + * id: string, + * properties: array, + * createdAt: string, + * updatedAt: string, + * archived: bool, + * }|string + */ + public function createDeal( + string $dealName, + string $dealStage, + string $closeDate = '', + string $amount = '', + string $dealType = '', + string $pipeline = '', + string $contactId = '', + string $companyId = '', + string $description = '', + array $properties = [], + ): array|string { + try { + $dealProperties = array_merge($properties, [ + 'dealname' => $dealName, + 'dealstage' => $dealStage, + 'closedate' => $closeDate, + 'amount' => $amount, + 'dealtype' => $dealType, + 'pipeline' => $pipeline, + 'description' => $description, + ]); + + // Remove empty values + $dealProperties = array_filter($dealProperties, fn ($value) => '' !== $value); + + $payload = [ + 'properties' => $dealProperties, + ]; + + // Add associations if provided + $associations = []; + if ($contactId) { + $associations[] = [ + 'to' => ['id' => $contactId], + 'types' => [ + [ + 'associationCategory' => 'HUBSPOT_DEFINED', + 'associationTypeId' => 3, // Contact to Deal association + ], + ], + ]; + } + if ($companyId) { + $associations[] = [ + 'to' => ['id' => $companyId], + 'types' => [ + [ + 'associationCategory' => 'HUBSPOT_DEFINED', + 'associationTypeId' => 5, // Company to Deal association + ], + ], + ]; + } + + if (!empty($associations)) { + $payload['associations'] = $associations; + } + + $response = $this->httpClient->request('POST', "https://api.hubapi.com/{$this->apiVersion}/objects/deals", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && 'error' === $data['status']) { + return 'Error creating deal: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'properties' => $data['properties'], + 'createdAt' => $data['createdAt'], + 'updatedAt' => $data['updatedAt'], + 'archived' => $data['archived'] ?? false, + ]; + } catch (\Exception $e) { + return 'Error creating deal: '.$e->getMessage(); + } + } + + /** + * Search HubSpot objects. + * + * @param string $objectType Object type (contacts, companies, deals, tickets, etc.) + * @param string $query Search query + * @param array $properties Properties to search in + * @param int $limit Maximum number of results + * @param string $after Pagination token + * + * @return array{ + * total: int, + * results: array, + * createdAt: string, + * updatedAt: string, + * archived: bool, + * }>, + * paging: array{next: array{after: string}|null}, + * }|string + */ + public function searchObjects( + string $objectType, + string $query, + array $properties = [], + int $limit = 100, + string $after = '', + ): array|string { + try { + $payload = [ + 'query' => $query, + 'limit' => min(max($limit, 1), 100), + ]; + + if (!empty($properties)) { + $payload['properties'] = $properties; + } + + if ($after) { + $payload['after'] = $after; + } + + $response = $this->httpClient->request('POST', "https://api.hubapi.com/{$this->apiVersion}/objects/{$objectType}/search", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && 'error' === $data['status']) { + return 'Error searching objects: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'total' => $data['total'] ?? 0, + 'results' => array_map(fn ($result) => [ + 'id' => $result['id'], + 'properties' => $result['properties'], + 'createdAt' => $result['createdAt'], + 'updatedAt' => $result['updatedAt'], + 'archived' => $result['archived'] ?? false, + ], $data['results']), + 'paging' => [ + 'next' => $data['paging']['next'] ?? null, + ], + ]; + } catch (\Exception $e) { + return 'Error searching objects: '.$e->getMessage(); + } + } + + /** + * Create a HubSpot ticket. + * + * @param string $subject Ticket subject + * @param string $content Ticket content + * @param string $priority Ticket priority (LOW, MEDIUM, HIGH) + * @param string $category Ticket category + * @param string $source Ticket source + * @param string $contactId Associated contact ID + * @param string $companyId Associated company ID + * @param array $properties Additional ticket properties + * + * @return array{ + * id: string, + * properties: array, + * createdAt: string, + * updatedAt: string, + * archived: bool, + * }|string + */ + public function createTicket( + string $subject, + string $content, + string $priority = 'MEDIUM', + string $category = '', + string $source = '', + string $contactId = '', + string $companyId = '', + array $properties = [], + ): array|string { + try { + $ticketProperties = array_merge($properties, [ + 'hs_ticket_priority' => $priority, + 'hs_pipeline_stage' => '1', // New ticket stage + 'subject' => $subject, + 'content' => $content, + 'hs_ticket_category' => $category, + 'hs_ticket_source' => $source, + ]); + + // Remove empty values + $ticketProperties = array_filter($ticketProperties, fn ($value) => '' !== $value); + + $payload = [ + 'properties' => $ticketProperties, + ]; + + // Add associations if provided + $associations = []; + if ($contactId) { + $associations[] = [ + 'to' => ['id' => $contactId], + 'types' => [ + [ + 'associationCategory' => 'HUBSPOT_DEFINED', + 'associationTypeId' => 16, // Contact to Ticket association + ], + ], + ]; + } + if ($companyId) { + $associations[] = [ + 'to' => ['id' => $companyId], + 'types' => [ + [ + 'associationCategory' => 'HUBSPOT_DEFINED', + 'associationTypeId' => 18, // Company to Ticket association + ], + ], + ]; + } + + if (!empty($associations)) { + $payload['associations'] = $associations; + } + + $response = $this->httpClient->request('POST', "https://api.hubapi.com/{$this->apiVersion}/objects/tickets", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && 'error' === $data['status']) { + return 'Error creating ticket: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'properties' => $data['properties'], + 'createdAt' => $data['createdAt'], + 'updatedAt' => $data['updatedAt'], + 'archived' => $data['archived'] ?? false, + ]; + } catch (\Exception $e) { + return 'Error creating ticket: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/HuggingFaceTts.php b/src/agent/src/Toolbox/Tool/HuggingFaceTts.php new file mode 100644 index 000000000..6844d75cd --- /dev/null +++ b/src/agent/src/Toolbox/Tool/HuggingFaceTts.php @@ -0,0 +1,960 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('huggingface_tts', 'Tool that converts text to speech using HuggingFace TTS')] +#[AsTool('huggingface_voice_clone', 'Tool that clones voices', method: 'voiceClone')] +#[AsTool('huggingface_voice_synthesis', 'Tool that synthesizes speech', method: 'voiceSynthesis')] +#[AsTool('huggingface_emotion_control', 'Tool that controls emotion in speech', method: 'emotionControl')] +#[AsTool('huggingface_speed_control', 'Tool that controls speech speed', method: 'speedControl')] +#[AsTool('huggingface_pitch_control', 'Tool that controls pitch', method: 'pitchControl')] +#[AsTool('huggingface_batch_tts', 'Tool that processes batch TTS', method: 'batchTts')] +#[AsTool('huggingface_voice_analysis', 'Tool that analyzes voice characteristics', method: 'voiceAnalysis')] +final readonly class HuggingFaceTts +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api-inference.huggingface.co/models', + private array $options = [], + ) { + } + + /** + * Convert text to speech using HuggingFace TTS. + * + * @param string $text Text to convert to speech + * @param string $voice Voice model to use + * @param string $language Language of the text + * @param array $options TTS options + * + * @return array{ + * success: bool, + * tts_result: array{ + * text: string, + * voice: string, + * language: string, + * audio_url: string, + * audio_format: string, + * duration: float, + * sample_rate: int, + * bit_rate: int, + * file_size: int, + * metadata: array{ + * model_used: string, + * generation_time: float, + * quality_score: float, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $text, + string $voice = 'default', + string $language = 'en', + array $options = [], + ): array { + try { + $requestData = [ + 'inputs' => $text, + 'parameters' => array_merge([ + 'voice' => $voice, + 'language' => $language, + ], $options), + ]; + + $modelEndpoint = $this->getModelEndpoint($voice, $language); + $response = $this->httpClient->request('POST', "{$this->baseUrl}/{$modelEndpoint}", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $audioData = $responseData[0] ?? []; + + return [ + 'success' => true, + 'tts_result' => [ + 'text' => $text, + 'voice' => $voice, + 'language' => $language, + 'audio_url' => $audioData['audio_url'] ?? '', + 'audio_format' => $audioData['audio_format'] ?? 'mp3', + 'duration' => $audioData['duration'] ?? 0.0, + 'sample_rate' => $audioData['sample_rate'] ?? 22050, + 'bit_rate' => $audioData['bit_rate'] ?? 128, + 'file_size' => $audioData['file_size'] ?? 0, + 'metadata' => [ + 'model_used' => $modelEndpoint, + 'generation_time' => $responseData['generation_time'] ?? 0.0, + 'quality_score' => $audioData['quality_score'] ?? 0.0, + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'tts_result' => [ + 'text' => $text, + 'voice' => $voice, + 'language' => $language, + 'audio_url' => '', + 'audio_format' => 'mp3', + 'duration' => 0.0, + 'sample_rate' => 22050, + 'bit_rate' => 128, + 'file_size' => 0, + 'metadata' => [ + 'model_used' => '', + 'generation_time' => 0.0, + 'quality_score' => 0.0, + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Clone voices. + * + * @param string $referenceAudio Reference audio file URL or base64 + * @param string $targetText Text to synthesize with cloned voice + * @param array $options Voice cloning options + * + * @return array{ + * success: bool, + * voice_clone: array{ + * reference_audio: string, + * target_text: string, + * cloned_audio_url: string, + * similarity_score: float, + * voice_characteristics: array{ + * pitch: float, + * speed: float, + * tone: string, + * accent: string, + * }, + * processing_metadata: array{ + * model_used: string, + * processing_time: float, + * quality_metrics: array, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function voiceClone( + string $referenceAudio, + string $targetText, + array $options = [], + ): array { + try { + $requestData = [ + 'reference_audio' => $referenceAudio, + 'target_text' => $targetText, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/voice-clone", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $voiceClone = $responseData['voice_clone'] ?? []; + + return [ + 'success' => true, + 'voice_clone' => [ + 'reference_audio' => $referenceAudio, + 'target_text' => $targetText, + 'cloned_audio_url' => $voiceClone['cloned_audio_url'] ?? '', + 'similarity_score' => $voiceClone['similarity_score'] ?? 0.0, + 'voice_characteristics' => [ + 'pitch' => $voiceClone['voice_characteristics']['pitch'] ?? 0.0, + 'speed' => $voiceClone['voice_characteristics']['speed'] ?? 1.0, + 'tone' => $voiceClone['voice_characteristics']['tone'] ?? 'neutral', + 'accent' => $voiceClone['voice_characteristics']['accent'] ?? 'neutral', + ], + 'processing_metadata' => [ + 'model_used' => $voiceClone['processing_metadata']['model_used'] ?? '', + 'processing_time' => $voiceClone['processing_metadata']['processing_time'] ?? 0.0, + 'quality_metrics' => $voiceClone['processing_metadata']['quality_metrics'] ?? [], + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'voice_clone' => [ + 'reference_audio' => $referenceAudio, + 'target_text' => $targetText, + 'cloned_audio_url' => '', + 'similarity_score' => 0.0, + 'voice_characteristics' => [ + 'pitch' => 0.0, + 'speed' => 1.0, + 'tone' => 'neutral', + 'accent' => 'neutral', + ], + 'processing_metadata' => [ + 'model_used' => '', + 'processing_time' => 0.0, + 'quality_metrics' => [], + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Synthesize speech with advanced options. + * + * @param string $text Text to synthesize + * @param array $voiceSettings Voice settings + * @param array $audioSettings Audio settings + * + * @return array{ + * success: bool, + * voice_synthesis: array{ + * text: string, + * voice_settings: array, + * audio_settings: array, + * synthesized_audio: array{ + * audio_url: string, + * format: string, + * duration: float, + * sample_rate: int, + * channels: int, + * bit_rate: int, + * }, + * synthesis_metrics: array{ + * naturalness_score: float, + * intelligibility_score: float, + * emotion_accuracy: float, + * pronunciation_accuracy: float, + * }, + * processing_info: array{ + * model_version: string, + * processing_time: float, + * memory_usage: float, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function voiceSynthesis( + string $text, + array $voiceSettings = [], + array $audioSettings = [], + ): array { + try { + $requestData = [ + 'text' => $text, + 'voice_settings' => $voiceSettings, + 'audio_settings' => $audioSettings, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/voice-synthesis", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $voiceSynthesis = $responseData['voice_synthesis'] ?? []; + + return [ + 'success' => true, + 'voice_synthesis' => [ + 'text' => $text, + 'voice_settings' => $voiceSettings, + 'audio_settings' => $audioSettings, + 'synthesized_audio' => [ + 'audio_url' => $voiceSynthesis['synthesized_audio']['audio_url'] ?? '', + 'format' => $voiceSynthesis['synthesized_audio']['format'] ?? 'mp3', + 'duration' => $voiceSynthesis['synthesized_audio']['duration'] ?? 0.0, + 'sample_rate' => $voiceSynthesis['synthesized_audio']['sample_rate'] ?? 22050, + 'channels' => $voiceSynthesis['synthesized_audio']['channels'] ?? 1, + 'bit_rate' => $voiceSynthesis['synthesized_audio']['bit_rate'] ?? 128, + ], + 'synthesis_metrics' => [ + 'naturalness_score' => $voiceSynthesis['synthesis_metrics']['naturalness_score'] ?? 0.0, + 'intelligibility_score' => $voiceSynthesis['synthesis_metrics']['intelligibility_score'] ?? 0.0, + 'emotion_accuracy' => $voiceSynthesis['synthesis_metrics']['emotion_accuracy'] ?? 0.0, + 'pronunciation_accuracy' => $voiceSynthesis['synthesis_metrics']['pronunciation_accuracy'] ?? 0.0, + ], + 'processing_info' => [ + 'model_version' => $voiceSynthesis['processing_info']['model_version'] ?? '', + 'processing_time' => $voiceSynthesis['processing_info']['processing_time'] ?? 0.0, + 'memory_usage' => $voiceSynthesis['processing_info']['memory_usage'] ?? 0.0, + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'voice_synthesis' => [ + 'text' => $text, + 'voice_settings' => $voiceSettings, + 'audio_settings' => $audioSettings, + 'synthesized_audio' => [ + 'audio_url' => '', + 'format' => 'mp3', + 'duration' => 0.0, + 'sample_rate' => 22050, + 'channels' => 1, + 'bit_rate' => 128, + ], + 'synthesis_metrics' => [ + 'naturalness_score' => 0.0, + 'intelligibility_score' => 0.0, + 'emotion_accuracy' => 0.0, + 'pronunciation_accuracy' => 0.0, + ], + 'processing_info' => [ + 'model_version' => '', + 'processing_time' => 0.0, + 'memory_usage' => 0.0, + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Control emotion in speech. + * + * @param string $text Text to synthesize + * @param string $emotion Emotion to apply + * @param float $intensity Emotion intensity (0.0 to 1.0) + * @param array $options Emotion control options + * + * @return array{ + * success: bool, + * emotion_control: array{ + * text: string, + * emotion: string, + * intensity: float, + * emotional_audio: array{ + * audio_url: string, + * format: string, + * duration: float, + * emotion_markers: array, + * }, + * emotion_analysis: array{ + * detected_emotions: array, + * emotion_transitions: array, + * overall_sentiment: string, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function emotionControl( + string $text, + string $emotion = 'neutral', + float $intensity = 0.5, + array $options = [], + ): array { + try { + $requestData = [ + 'text' => $text, + 'emotion' => $emotion, + 'intensity' => max(0.0, min($intensity, 1.0)), + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/emotion-control", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $emotionControl = $responseData['emotion_control'] ?? []; + + return [ + 'success' => true, + 'emotion_control' => [ + 'text' => $text, + 'emotion' => $emotion, + 'intensity' => $intensity, + 'emotional_audio' => [ + 'audio_url' => $emotionControl['emotional_audio']['audio_url'] ?? '', + 'format' => $emotionControl['emotional_audio']['format'] ?? 'mp3', + 'duration' => $emotionControl['emotional_audio']['duration'] ?? 0.0, + 'emotion_markers' => array_map(fn ($marker) => [ + 'timestamp' => $marker['timestamp'] ?? 0.0, + 'emotion' => $marker['emotion'] ?? '', + 'confidence' => $marker['confidence'] ?? 0.0, + ], $emotionControl['emotional_audio']['emotion_markers'] ?? []), + ], + 'emotion_analysis' => [ + 'detected_emotions' => $emotionControl['emotion_analysis']['detected_emotions'] ?? [], + 'emotion_transitions' => array_map(fn ($transition) => [ + 'from_emotion' => $transition['from_emotion'] ?? '', + 'to_emotion' => $transition['to_emotion'] ?? '', + 'transition_time' => $transition['transition_time'] ?? 0.0, + ], $emotionControl['emotion_analysis']['emotion_transitions'] ?? []), + 'overall_sentiment' => $emotionControl['emotion_analysis']['overall_sentiment'] ?? 'neutral', + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'emotion_control' => [ + 'text' => $text, + 'emotion' => $emotion, + 'intensity' => $intensity, + 'emotional_audio' => [ + 'audio_url' => '', + 'format' => 'mp3', + 'duration' => 0.0, + 'emotion_markers' => [], + ], + 'emotion_analysis' => [ + 'detected_emotions' => [], + 'emotion_transitions' => [], + 'overall_sentiment' => 'neutral', + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Control speech speed. + * + * @param string $text Text to synthesize + * @param float $speed Speech speed (0.5 to 2.0) + * @param array $options Speed control options + * + * @return array{ + * success: bool, + * speed_control: array{ + * text: string, + * speed: float, + * speed_controlled_audio: array{ + * audio_url: string, + * format: string, + * duration: float, + * original_duration: float, + * speed_factor: float, + * }, + * timing_analysis: array{ + * word_timings: array, + * pause_durations: array, + * rhythm_pattern: array, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function speedControl( + string $text, + float $speed = 1.0, + array $options = [], + ): array { + try { + $requestData = [ + 'text' => $text, + 'speed' => max(0.5, min($speed, 2.0)), + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/speed-control", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $speedControl = $responseData['speed_control'] ?? []; + + return [ + 'success' => true, + 'speed_control' => [ + 'text' => $text, + 'speed' => $speed, + 'speed_controlled_audio' => [ + 'audio_url' => $speedControl['speed_controlled_audio']['audio_url'] ?? '', + 'format' => $speedControl['speed_controlled_audio']['format'] ?? 'mp3', + 'duration' => $speedControl['speed_controlled_audio']['duration'] ?? 0.0, + 'original_duration' => $speedControl['speed_controlled_audio']['original_duration'] ?? 0.0, + 'speed_factor' => $speedControl['speed_controlled_audio']['speed_factor'] ?? 1.0, + ], + 'timing_analysis' => [ + 'word_timings' => array_map(fn ($timing) => [ + 'word' => $timing['word'] ?? '', + 'start_time' => $timing['start_time'] ?? 0.0, + 'end_time' => $timing['end_time'] ?? 0.0, + 'duration' => $timing['duration'] ?? 0.0, + ], $speedControl['timing_analysis']['word_timings'] ?? []), + 'pause_durations' => $speedControl['timing_analysis']['pause_durations'] ?? [], + 'rhythm_pattern' => $speedControl['timing_analysis']['rhythm_pattern'] ?? [], + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'speed_control' => [ + 'text' => $text, + 'speed' => $speed, + 'speed_controlled_audio' => [ + 'audio_url' => '', + 'format' => 'mp3', + 'duration' => 0.0, + 'original_duration' => 0.0, + 'speed_factor' => 1.0, + ], + 'timing_analysis' => [ + 'word_timings' => [], + 'pause_durations' => [], + 'rhythm_pattern' => [], + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Control pitch. + * + * @param string $text Text to synthesize + * @param float $pitch Pitch adjustment (-12 to +12 semitones) + * @param array $options Pitch control options + * + * @return array{ + * success: bool, + * pitch_control: array{ + * text: string, + * pitch: float, + * pitch_controlled_audio: array{ + * audio_url: string, + * format: string, + * duration: float, + * original_pitch: float, + * new_pitch: float, + * }, + * pitch_analysis: array{ + * fundamental_frequency: array, + * pitch_contour: array, + * pitch_variability: float, + * average_pitch: float, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function pitchControl( + string $text, + float $pitch = 0.0, + array $options = [], + ): array { + try { + $requestData = [ + 'text' => $text, + 'pitch' => max(-12.0, min($pitch, 12.0)), + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/pitch-control", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $pitchControl = $responseData['pitch_control'] ?? []; + + return [ + 'success' => true, + 'pitch_control' => [ + 'text' => $text, + 'pitch' => $pitch, + 'pitch_controlled_audio' => [ + 'audio_url' => $pitchControl['pitch_controlled_audio']['audio_url'] ?? '', + 'format' => $pitchControl['pitch_controlled_audio']['format'] ?? 'mp3', + 'duration' => $pitchControl['pitch_controlled_audio']['duration'] ?? 0.0, + 'original_pitch' => $pitchControl['pitch_controlled_audio']['original_pitch'] ?? 0.0, + 'new_pitch' => $pitchControl['pitch_controlled_audio']['new_pitch'] ?? 0.0, + ], + 'pitch_analysis' => [ + 'fundamental_frequency' => $pitchControl['pitch_analysis']['fundamental_frequency'] ?? [], + 'pitch_contour' => array_map(fn ($contour) => [ + 'timestamp' => $contour['timestamp'] ?? 0.0, + 'frequency' => $contour['frequency'] ?? 0.0, + 'note' => $contour['note'] ?? '', + ], $pitchControl['pitch_analysis']['pitch_contour'] ?? []), + 'pitch_variability' => $pitchControl['pitch_analysis']['pitch_variability'] ?? 0.0, + 'average_pitch' => $pitchControl['pitch_analysis']['average_pitch'] ?? 0.0, + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'pitch_control' => [ + 'text' => $text, + 'pitch' => $pitch, + 'pitch_controlled_audio' => [ + 'audio_url' => '', + 'format' => 'mp3', + 'duration' => 0.0, + 'original_pitch' => 0.0, + 'new_pitch' => 0.0, + ], + 'pitch_analysis' => [ + 'fundamental_frequency' => [], + 'pitch_contour' => [], + 'pitch_variability' => 0.0, + 'average_pitch' => 0.0, + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Process batch TTS. + * + * @param array $texts Array of texts to convert + * @param array $options Batch processing options + * + * @return array{ + * success: bool, + * batch_tts: array{ + * texts: array, + * batch_results: array, + * batch_summary: array{ + * total_texts: int, + * successful: int, + * failed: int, + * total_duration: float, + * processing_time: float, + * }, + * batch_audio_url: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function batchTts( + array $texts, + array $options = [], + ): array { + try { + $requestData = [ + 'texts' => $texts, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/batch-tts", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $batchTts = $responseData['batch_tts'] ?? []; + + return [ + 'success' => true, + 'batch_tts' => [ + 'texts' => $texts, + 'batch_results' => array_map(fn ($result) => [ + 'text' => $result['text'] ?? '', + 'audio_url' => $result['audio_url'] ?? '', + 'duration' => $result['duration'] ?? 0.0, + 'success' => $result['success'] ?? false, + 'error' => $result['error'] ?? '', + ], $batchTts['batch_results'] ?? []), + 'batch_summary' => [ + 'total_texts' => $batchTts['batch_summary']['total_texts'] ?? \count($texts), + 'successful' => $batchTts['batch_summary']['successful'] ?? 0, + 'failed' => $batchTts['batch_summary']['failed'] ?? 0, + 'total_duration' => $batchTts['batch_summary']['total_duration'] ?? 0.0, + 'processing_time' => $batchTts['batch_summary']['processing_time'] ?? 0.0, + ], + 'batch_audio_url' => $batchTts['batch_audio_url'] ?? '', + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'batch_tts' => [ + 'texts' => $texts, + 'batch_results' => [], + 'batch_summary' => [ + 'total_texts' => \count($texts), + 'successful' => 0, + 'failed' => \count($texts), + 'total_duration' => 0.0, + 'processing_time' => 0.0, + ], + 'batch_audio_url' => '', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze voice characteristics. + * + * @param string $audioUrl Audio file URL to analyze + * @param array $analysisOptions Analysis options + * + * @return array{ + * success: bool, + * voice_analysis: array{ + * audio_url: string, + * voice_characteristics: array{ + * fundamental_frequency: float, + * formants: array, + * jitter: float, + * shimmer: float, + * hnr: float, + * mfcc: array, + * }, + * prosody_analysis: array{ + * speaking_rate: float, + * pause_frequency: float, + * pitch_range: float, + * intensity_variation: float, + * rhythm_pattern: array, + * }, + * emotion_analysis: array{ + * detected_emotions: array, + * emotion_intensity: float, + * valence: float, + * arousal: float, + * }, + * quality_metrics: array{ + * clarity_score: float, + * naturalness_score: float, + * intelligibility_score: float, + * noise_level: float, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function voiceAnalysis( + string $audioUrl, + array $analysisOptions = [], + ): array { + try { + $requestData = [ + 'audio_url' => $audioUrl, + 'analysis_options' => $analysisOptions, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/voice-analysis", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $voiceAnalysis = $responseData['voice_analysis'] ?? []; + + return [ + 'success' => true, + 'voice_analysis' => [ + 'audio_url' => $audioUrl, + 'voice_characteristics' => [ + 'fundamental_frequency' => $voiceAnalysis['voice_characteristics']['fundamental_frequency'] ?? 0.0, + 'formants' => $voiceAnalysis['voice_characteristics']['formants'] ?? [], + 'jitter' => $voiceAnalysis['voice_characteristics']['jitter'] ?? 0.0, + 'shimmer' => $voiceAnalysis['voice_characteristics']['shimmer'] ?? 0.0, + 'hnr' => $voiceAnalysis['voice_characteristics']['hnr'] ?? 0.0, + 'mfcc' => $voiceAnalysis['voice_characteristics']['mfcc'] ?? [], + ], + 'prosody_analysis' => [ + 'speaking_rate' => $voiceAnalysis['prosody_analysis']['speaking_rate'] ?? 0.0, + 'pause_frequency' => $voiceAnalysis['prosody_analysis']['pause_frequency'] ?? 0.0, + 'pitch_range' => $voiceAnalysis['prosody_analysis']['pitch_range'] ?? 0.0, + 'intensity_variation' => $voiceAnalysis['prosody_analysis']['intensity_variation'] ?? 0.0, + 'rhythm_pattern' => $voiceAnalysis['prosody_analysis']['rhythm_pattern'] ?? [], + ], + 'emotion_analysis' => [ + 'detected_emotions' => $voiceAnalysis['emotion_analysis']['detected_emotions'] ?? [], + 'emotion_intensity' => $voiceAnalysis['emotion_analysis']['emotion_intensity'] ?? 0.0, + 'valence' => $voiceAnalysis['emotion_analysis']['valence'] ?? 0.0, + 'arousal' => $voiceAnalysis['emotion_analysis']['arousal'] ?? 0.0, + ], + 'quality_metrics' => [ + 'clarity_score' => $voiceAnalysis['quality_metrics']['clarity_score'] ?? 0.0, + 'naturalness_score' => $voiceAnalysis['quality_metrics']['naturalness_score'] ?? 0.0, + 'intelligibility_score' => $voiceAnalysis['quality_metrics']['intelligibility_score'] ?? 0.0, + 'noise_level' => $voiceAnalysis['quality_metrics']['noise_level'] ?? 0.0, + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'voice_analysis' => [ + 'audio_url' => $audioUrl, + 'voice_characteristics' => [ + 'fundamental_frequency' => 0.0, + 'formants' => [], + 'jitter' => 0.0, + 'shimmer' => 0.0, + 'hnr' => 0.0, + 'mfcc' => [], + ], + 'prosody_analysis' => [ + 'speaking_rate' => 0.0, + 'pause_frequency' => 0.0, + 'pitch_range' => 0.0, + 'intensity_variation' => 0.0, + 'rhythm_pattern' => [], + ], + 'emotion_analysis' => [ + 'detected_emotions' => [], + 'emotion_intensity' => 0.0, + 'valence' => 0.0, + 'arousal' => 0.0, + ], + 'quality_metrics' => [ + 'clarity_score' => 0.0, + 'naturalness_score' => 0.0, + 'intelligibility_score' => 0.0, + 'noise_level' => 0.0, + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get model endpoint based on voice and language. + */ + private function getModelEndpoint(string $voice, string $language): string + { + $modelMap = [ + 'default' => [ + 'en' => 'facebook/tts-models', + 'es' => 'facebook/tts-models-es', + 'fr' => 'facebook/tts-models-fr', + 'de' => 'facebook/tts-models-de', + ], + 'female' => [ + 'en' => 'facebook/tts-female-en', + 'es' => 'facebook/tts-female-es', + 'fr' => 'facebook/tts-female-fr', + ], + 'male' => [ + 'en' => 'facebook/tts-male-en', + 'es' => 'facebook/tts-male-es', + 'fr' => 'facebook/tts-male-fr', + ], + ]; + + return $modelMap[$voice][$language] ?? $modelMap['default']['en']; + } +} diff --git a/src/agent/src/Toolbox/Tool/HumanInput.php b/src/agent/src/Toolbox/Tool/HumanInput.php new file mode 100644 index 000000000..d2a50ec34 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/HumanInput.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; + +/** + * @author Mathieu Ledru + */ +#[AsTool('human', 'Tool that asks user for input when the agent needs guidance')] +final readonly class HumanInput +{ + public function __construct( + private mixed $promptFunc = null, + private mixed $inputFunc = null, + ) { + } + + /** + * Ask user for input when the agent needs guidance. + * + * @param string $query The question to ask the human + */ + public function __invoke( + #[With(maximum: 1000)] + string $query, + ): string { + // Use custom prompt function or default + $promptFunc = $this->promptFunc ?? fn (string $text) => $this->defaultPrompt($text); + $inputFunc = $this->inputFunc ?? fn () => $this->defaultInput(); + + // Display the prompt to the user + $promptFunc($query); + + // Get input from the user + $response = $inputFunc(); + + return $response; + } + + /** + * Default prompt function that prints to console. + */ + private function defaultPrompt(string $text): void + { + echo "\n".$text."\n"; + } + + /** + * Default input function that reads from console. + */ + private function defaultInput(): string + { + $handle = fopen('php://stdin', 'r'); + $input = trim(fgets($handle)); + fclose($handle); + + return $input; + } +} diff --git a/src/agent/src/Toolbox/Tool/IFTTT.php b/src/agent/src/Toolbox/Tool/IFTTT.php new file mode 100644 index 000000000..92086a1c2 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/IFTTT.php @@ -0,0 +1,405 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('ifttt_trigger_webhook', 'Tool that triggers IFTTT webhooks')] +#[AsTool('ifttt_create_applet', 'Tool that creates IFTTT applets', method: 'createApplet')] +#[AsTool('ifttt_list_applets', 'Tool that lists IFTTT applets', method: 'listApplets')] +#[AsTool('ifttt_get_applet', 'Tool that gets IFTTT applet details', method: 'getApplet')] +#[AsTool('ifttt_update_applet', 'Tool that updates IFTTT applets', method: 'updateApplet')] +#[AsTool('ifttt_delete_applet', 'Tool that deletes IFTTT applets', method: 'deleteApplet')] +final readonly class IFTTT +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $baseUrl = 'https://ifttt.com/maker_webhooks', + private array $options = [], + ) { + } + + /** + * Trigger IFTTT webhook. + * + * @param string $eventName Webhook event name + * @param array $values Values to send to webhook + * + * @return array{ + * success: bool, + * status: int, + * response: array, + * error: string, + * } + */ + public function __invoke( + string $eventName, + array $values = [], + ): array { + try { + $body = [ + 'value1' => $values['value1'] ?? '', + 'value2' => $values['value2'] ?? '', + 'value3' => $values['value3'] ?? '', + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/trigger/{$eventName}/with/key/{$this->apiKey}", [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => $body, + ]); + + $statusCode = $response->getStatusCode(); + $responseData = $response->toArray(); + + return [ + 'success' => $statusCode >= 200 && $statusCode < 300, + 'status' => $statusCode, + 'response' => $responseData, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'status' => 0, + 'response' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create IFTTT applet. + * + * @param string $name Applet name + * @param string $trigger Trigger service and event + * @param string $action Action service and event + * @param array $triggerFields Trigger field values + * @param array $actionFields Action field values + * + * @return array{ + * success: bool, + * appletId: string, + * url: string, + * error: string, + * } + */ + public function createApplet( + string $name, + string $trigger, + string $action, + array $triggerFields = [], + array $actionFields = [], + ): array { + try { + $body = [ + 'name' => $name, + 'trigger' => $trigger, + 'action' => $action, + 'triggerFields' => $triggerFields, + 'actionFields' => $actionFields, + ]; + + $response = $this->httpClient->request('POST', 'https://ifttt.com/api/v1/applets', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $body, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return [ + 'success' => false, + 'appletId' => '', + 'url' => '', + 'error' => $data['error']['message'] ?? 'Unknown error', + ]; + } + + return [ + 'success' => true, + 'appletId' => $data['id'] ?? '', + 'url' => $data['url'] ?? '', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'appletId' => '', + 'url' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * List IFTTT applets. + * + * @param int $limit Number of applets to return + * @param int $offset Offset for pagination + * + * @return array + */ + public function listApplets( + int $limit = 50, + int $offset = 0, + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), + 'offset' => max($offset, 0), + ]; + + $response = $this->httpClient->request('GET', 'https://ifttt.com/api/v1/applets', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($applet) => [ + 'id' => $applet['id'], + 'name' => $applet['name'], + 'status' => $applet['status'], + 'trigger' => [ + 'service' => $applet['trigger']['service'], + 'event' => $applet['trigger']['event'], + ], + 'action' => [ + 'service' => $applet['action']['service'], + 'event' => $applet['action']['event'], + ], + 'created' => $applet['created'], + 'updated' => $applet['updated'], + 'url' => $applet['url'], + ], $data['applets'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get IFTTT applet details. + * + * @param string $appletId Applet ID + * + * @return array{ + * id: string, + * name: string, + * status: string, + * trigger: array{ + * service: string, + * event: string, + * fields: array, + * }, + * action: array{ + * service: string, + * event: string, + * fields: array, + * }, + * created: string, + * updated: string, + * url: string, + * runCount: int, + * }|string + */ + public function getApplet(string $appletId): array|string + { + try { + $response = $this->httpClient->request('GET', "https://ifttt.com/api/v1/applets/{$appletId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting applet: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'status' => $data['status'], + 'trigger' => [ + 'service' => $data['trigger']['service'], + 'event' => $data['trigger']['event'], + 'fields' => $data['trigger']['fields'] ?? [], + ], + 'action' => [ + 'service' => $data['action']['service'], + 'event' => $data['action']['event'], + 'fields' => $data['action']['fields'] ?? [], + ], + 'created' => $data['created'], + 'updated' => $data['updated'], + 'url' => $data['url'], + 'runCount' => $data['runCount'] ?? 0, + ]; + } catch (\Exception $e) { + return 'Error getting applet: '.$e->getMessage(); + } + } + + /** + * Update IFTTT applet. + * + * @param string $appletId Applet ID + * @param string $name New applet name + * @param string $status New applet status + * @param array $triggerFields Updated trigger field values + * @param array $actionFields Updated action field values + * + * @return array{ + * success: bool, + * appletId: string, + * url: string, + * error: string, + * } + */ + public function updateApplet( + string $appletId, + string $name = '', + string $status = '', + array $triggerFields = [], + array $actionFields = [], + ): array { + try { + $body = []; + + if ($name) { + $body['name'] = $name; + } + if ($status) { + $body['status'] = $status; + } + if (!empty($triggerFields)) { + $body['triggerFields'] = $triggerFields; + } + if (!empty($actionFields)) { + $body['actionFields'] = $actionFields; + } + + $response = $this->httpClient->request('PATCH', "https://ifttt.com/api/v1/applets/{$appletId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $body, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return [ + 'success' => false, + 'appletId' => '', + 'url' => '', + 'error' => $data['error']['message'] ?? 'Unknown error', + ]; + } + + return [ + 'success' => true, + 'appletId' => $data['id'] ?? $appletId, + 'url' => $data['url'] ?? '', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'appletId' => '', + 'url' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Delete IFTTT applet. + * + * @param string $appletId Applet ID + * + * @return array{ + * success: bool, + * error: string, + * } + */ + public function deleteApplet(string $appletId): array + { + try { + $response = $this->httpClient->request('DELETE', "https://ifttt.com/api/v1/applets/{$appletId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + ]); + + $statusCode = $response->getStatusCode(); + + if ($statusCode >= 400) { + $data = $response->toArray(); + + return [ + 'success' => false, + 'error' => $data['error']['message'] ?? 'Failed to delete applet', + ]; + } + + return [ + 'success' => true, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Instagram.php b/src/agent/src/Toolbox/Tool/Instagram.php new file mode 100644 index 000000000..1c2e7b984 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Instagram.php @@ -0,0 +1,499 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('instagram_get_media', 'Tool that gets Instagram media and posts')] +#[AsTool('instagram_get_user_info', 'Tool that gets Instagram user information', method: 'getUserInfo')] +#[AsTool('instagram_search_hashtags', 'Tool that searches Instagram hashtags', method: 'searchHashtags')] +#[AsTool('instagram_get_media_insights', 'Tool that gets Instagram media insights', method: 'getMediaInsights')] +#[AsTool('instagram_get_user_insights', 'Tool that gets Instagram user insights', method: 'getUserInsights')] +#[AsTool('instagram_create_media_container', 'Tool that creates Instagram media containers', method: 'createMediaContainer')] +final readonly class Instagram +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private string $apiVersion = 'v18.0', + private array $options = [], + ) { + } + + /** + * Get Instagram media and posts. + * + * @param string $userId Instagram user ID + * @param int $limit Number of media items to retrieve (1-100) + * @param string $fields Fields to retrieve (id,caption,media_type,media_url,permalink,thumbnail_url,timestamp,username) + * + * @return array + */ + public function __invoke( + string $userId, + int $limit = 25, + string $fields = 'id,caption,media_type,media_url,permalink,thumbnail_url,timestamp,username,like_count,comments_count', + ): array { + try { + $params = [ + 'fields' => $fields, + 'limit' => min(max($limit, 1), 100), + ]; + + $response = $this->httpClient->request('GET', "https://graph.instagram.com/{$this->apiVersion}/{$userId}/media", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return []; + } + + $media = []; + foreach ($data['data'] as $item) { + $media[] = [ + 'id' => $item['id'], + 'caption' => $item['caption'] ?? '', + 'media_type' => $item['media_type'] ?? 'IMAGE', + 'media_url' => $item['media_url'] ?? '', + 'permalink' => $item['permalink'] ?? '', + 'thumbnail_url' => $item['thumbnail_url'] ?? '', + 'timestamp' => $item['timestamp'] ?? '', + 'username' => $item['username'] ?? '', + 'like_count' => $item['like_count'] ?? 0, + 'comments_count' => $item['comments_count'] ?? 0, + ]; + } + + return $media; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Instagram user information. + * + * @param string $userId Instagram user ID + * + * @return array{ + * id: string, + * username: string, + * account_type: string, + * media_count: int, + * followers_count: int, + * follows_count: int, + * name: string, + * biography: string, + * website: string, + * profile_picture_url: string, + * }|string + */ + public function getUserInfo(string $userId): array|string + { + try { + $fields = 'id,username,account_type,media_count,followers_count,follows_count,name,biography,website,profile_picture_url'; + + $response = $this->httpClient->request('GET', "https://graph.instagram.com/{$this->apiVersion}/{$userId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => [ + 'fields' => $fields, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting user info: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'username' => $data['username'], + 'account_type' => $data['account_type'] ?? 'PERSONAL', + 'media_count' => $data['media_count'] ?? 0, + 'followers_count' => $data['followers_count'] ?? 0, + 'follows_count' => $data['follows_count'] ?? 0, + 'name' => $data['name'] ?? '', + 'biography' => $data['biography'] ?? '', + 'website' => $data['website'] ?? '', + 'profile_picture_url' => $data['profile_picture_url'] ?? '', + ]; + } catch (\Exception $e) { + return 'Error getting user info: '.$e->getMessage(); + } + } + + /** + * Search Instagram hashtags. + * + * @param string $hashtag Hashtag to search for (without #) + * @param int $limit Number of results (1-100) + * + * @return array + */ + public function searchHashtags( + #[With(maximum: 100)] + string $hashtag, + int $limit = 10, + ): array { + try { + $hashtag = ltrim($hashtag, '#'); + + $response = $this->httpClient->request('GET', "https://graph.instagram.com/{$this->apiVersion}/ig_hashtag_search", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => [ + 'user_id' => $this->getUserId(), + 'q' => $hashtag, + ], + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return []; + } + + $hashtags = []; + foreach (\array_slice($data['data'], 0, $limit) as $item) { + $hashtags[] = [ + 'id' => $item['id'], + 'name' => $item['name'], + 'media_count' => $item['media_count'] ?? 0, + ]; + } + + return $hashtags; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Instagram media insights. + * + * @param string $mediaId Instagram media ID + * + * @return array{ + * impressions: int, + * reach: int, + * likes: int, + * comments: int, + * shares: int, + * saves: int, + * video_views: int, + * profile_visits: int, + * website_clicks: int, + * }|string + */ + public function getMediaInsights(string $mediaId): array|string + { + try { + $response = $this->httpClient->request('GET', "https://graph.instagram.com/{$this->apiVersion}/{$mediaId}/insights", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => [ + 'metric' => 'impressions,reach,likes,comments,shares,saves,video_views,profile_visits,website_clicks', + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting media insights: '.($data['error']['message'] ?? 'Unknown error'); + } + + $insights = [ + 'impressions' => 0, + 'reach' => 0, + 'likes' => 0, + 'comments' => 0, + 'shares' => 0, + 'saves' => 0, + 'video_views' => 0, + 'profile_visits' => 0, + 'website_clicks' => 0, + ]; + + foreach ($data['data'] as $insight) { + $metric = $insight['name']; + $value = $insight['values'][0]['value'] ?? 0; + + if (isset($insights[$metric])) { + $insights[$metric] = $value; + } + } + + return $insights; + } catch (\Exception $e) { + return 'Error getting media insights: '.$e->getMessage(); + } + } + + /** + * Get Instagram user insights. + * + * @param string $userId Instagram user ID + * + * @return array{ + * impressions: int, + * reach: int, + * profile_views: int, + * website_clicks: int, + * email_contacts: int, + * phone_call_clicks: int, + * text_message_clicks: int, + * follower_count: int, + * }|string + */ + public function getUserInsights(string $userId): array|string + { + try { + $response = $this->httpClient->request('GET', "https://graph.instagram.com/{$this->apiVersion}/{$userId}/insights", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => [ + 'metric' => 'impressions,reach,profile_views,website_clicks,email_contacts,phone_call_clicks,text_message_clicks,follower_count', + 'period' => 'day', + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting user insights: '.($data['error']['message'] ?? 'Unknown error'); + } + + $insights = [ + 'impressions' => 0, + 'reach' => 0, + 'profile_views' => 0, + 'website_clicks' => 0, + 'email_contacts' => 0, + 'phone_call_clicks' => 0, + 'text_message_clicks' => 0, + 'follower_count' => 0, + ]; + + foreach ($data['data'] as $insight) { + $metric = $insight['name']; + $value = $insight['values'][0]['value'] ?? 0; + + if (isset($insights[$metric])) { + $insights[$metric] = $value; + } + } + + return $insights; + } catch (\Exception $e) { + return 'Error getting user insights: '.$e->getMessage(); + } + } + + /** + * Create Instagram media container for posting. + * + * @param string $imageUrl URL of the image to post + * @param string $caption Caption for the post + * @param string $mediaType Media type (IMAGE, VIDEO, CAROUSEL_ALBUM) + * + * @return array{ + * id: string, + * status_code: string, + * }|string + */ + public function createMediaContainer( + string $imageUrl, + string $caption, + string $mediaType = 'IMAGE', + ): array|string { + try { + $payload = [ + 'image_url' => $imageUrl, + 'caption' => $caption, + 'media_type' => $mediaType, + ]; + + $response = $this->httpClient->request('POST', "https://graph.instagram.com/{$this->apiVersion}/{$this->getUserId()}/media", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating media container: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'status_code' => $data['status_code'] ?? 'FINISHED', + ]; + } catch (\Exception $e) { + return 'Error creating media container: '.$e->getMessage(); + } + } + + /** + * Publish Instagram media container. + * + * @param string $creationId Media container ID + * + * @return array{ + * id: string, + * }|string + */ + public function publishMediaContainer(string $creationId): array|string + { + try { + $response = $this->httpClient->request('POST', "https://graph.instagram.com/{$this->apiVersion}/{$this->getUserId()}/media_publish", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'creation_id' => $creationId, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error publishing media container: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + ]; + } catch (\Exception $e) { + return 'Error publishing media container: '.$e->getMessage(); + } + } + + /** + * Get Instagram media by hashtag. + * + * @param string $hashtagId Instagram hashtag ID + * @param int $limit Number of media items (1-100) + * + * @return array + */ + public function getMediaByHashtag(string $hashtagId, int $limit = 25): array + { + try { + $response = $this->httpClient->request('GET', "https://graph.instagram.com/{$this->apiVersion}/{$hashtagId}/top_media", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => [ + 'user_id' => $this->getUserId(), + 'fields' => 'id,caption,media_type,media_url,permalink,timestamp,username,like_count,comments_count', + 'limit' => min(max($limit, 1), 100), + ], + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return []; + } + + $media = []; + foreach ($data['data'] as $item) { + $media[] = [ + 'id' => $item['id'], + 'caption' => $item['caption'] ?? '', + 'media_type' => $item['media_type'] ?? 'IMAGE', + 'media_url' => $item['media_url'] ?? '', + 'permalink' => $item['permalink'] ?? '', + 'timestamp' => $item['timestamp'] ?? '', + 'username' => $item['username'] ?? '', + 'like_count' => $item['like_count'] ?? 0, + 'comments_count' => $item['comments_count'] ?? 0, + ]; + } + + return $media; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get user ID from access token. + */ + private function getUserId(): string + { + try { + $response = $this->httpClient->request('GET', "https://graph.instagram.com/{$this->apiVersion}/me", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => [ + 'fields' => 'id', + ], + ]); + + $data = $response->toArray(); + + return $data['id'] ?? ''; + } catch (\Exception $e) { + return ''; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Interaction.php b/src/agent/src/Toolbox/Tool/Interaction.php new file mode 100644 index 000000000..070fdf7f0 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Interaction.php @@ -0,0 +1,921 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('interaction_create', 'Tool that creates user interactions using Interaction API')] +#[AsTool('interaction_track', 'Tool that tracks user interactions', method: 'track')] +#[AsTool('interaction_analyze', 'Tool that analyzes user interactions', method: 'analyze')] +#[AsTool('interaction_survey', 'Tool that creates surveys', method: 'survey')] +#[AsTool('interaction_feedback', 'Tool that collects feedback', method: 'feedback')] +#[AsTool('interaction_engagement', 'Tool that measures engagement', method: 'engagement')] +#[AsTool('interaction_behavior', 'Tool that analyzes user behavior', method: 'behavior')] +#[AsTool('interaction_personalization', 'Tool that provides personalization', method: 'personalization')] +final readonly class Interaction +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.interaction.com/v1', + private array $options = [], + ) { + } + + /** + * Create user interactions using Interaction API. + * + * @param string $userId User ID + * @param string $interactionType Type of interaction + * @param array $data Interaction data + * @param array $context Interaction context + * + * @return array{ + * success: bool, + * interaction: array{ + * interaction_id: string, + * user_id: string, + * interaction_type: string, + * data: array, + * context: array, + * timestamp: string, + * session_id: string, + * device_info: array{ + * type: string, + * os: string, + * browser: string, + * }, + * location: array{ + * country: string, + * region: string, + * city: string, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $userId, + string $interactionType, + array $data = [], + array $context = [], + ): array { + try { + $requestData = [ + 'user_id' => $userId, + 'interaction_type' => $interactionType, + 'data' => $data, + 'context' => $context, + 'timestamp' => date('c'), + 'session_id' => $context['session_id'] ?? uniqid(), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/interactions", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $interaction = $responseData['interaction'] ?? []; + + return [ + 'success' => true, + 'interaction' => [ + 'interaction_id' => $interaction['interaction_id'] ?? '', + 'user_id' => $userId, + 'interaction_type' => $interactionType, + 'data' => $data, + 'context' => $context, + 'timestamp' => $interaction['timestamp'] ?? date('c'), + 'session_id' => $context['session_id'] ?? uniqid(), + 'device_info' => [ + 'type' => $interaction['device_info']['type'] ?? 'unknown', + 'os' => $interaction['device_info']['os'] ?? 'unknown', + 'browser' => $interaction['device_info']['browser'] ?? 'unknown', + ], + 'location' => [ + 'country' => $interaction['location']['country'] ?? 'unknown', + 'region' => $interaction['location']['region'] ?? 'unknown', + 'city' => $interaction['location']['city'] ?? 'unknown', + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'interaction' => [ + 'interaction_id' => '', + 'user_id' => $userId, + 'interaction_type' => $interactionType, + 'data' => $data, + 'context' => $context, + 'timestamp' => date('c'), + 'session_id' => $context['session_id'] ?? uniqid(), + 'device_info' => [ + 'type' => 'unknown', + 'os' => 'unknown', + 'browser' => 'unknown', + ], + 'location' => [ + 'country' => 'unknown', + 'region' => 'unknown', + 'city' => 'unknown', + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Track user interactions. + * + * @param string $userId User ID + * @param string $eventType Event type + * @param array $properties Event properties + * @param array $metadata Event metadata + * + * @return array{ + * success: bool, + * tracking: array{ + * event_id: string, + * user_id: string, + * event_type: string, + * properties: array, + * metadata: array, + * timestamp: string, + * session_id: string, + * funnel_step: string, + * conversion_value: float, + * }, + * processingTime: float, + * error: string, + * } + */ + public function track( + string $userId, + string $eventType, + array $properties = [], + array $metadata = [], + ): array { + try { + $requestData = [ + 'user_id' => $userId, + 'event_type' => $eventType, + 'properties' => $properties, + 'metadata' => $metadata, + 'timestamp' => date('c'), + 'session_id' => $metadata['session_id'] ?? uniqid(), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/track", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $tracking = $responseData['tracking'] ?? []; + + return [ + 'success' => true, + 'tracking' => [ + 'event_id' => $tracking['event_id'] ?? '', + 'user_id' => $userId, + 'event_type' => $eventType, + 'properties' => $properties, + 'metadata' => $metadata, + 'timestamp' => $tracking['timestamp'] ?? date('c'), + 'session_id' => $metadata['session_id'] ?? uniqid(), + 'funnel_step' => $tracking['funnel_step'] ?? '', + 'conversion_value' => $tracking['conversion_value'] ?? 0.0, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'tracking' => [ + 'event_id' => '', + 'user_id' => $userId, + 'event_type' => $eventType, + 'properties' => $properties, + 'metadata' => $metadata, + 'timestamp' => date('c'), + 'session_id' => $metadata['session_id'] ?? uniqid(), + 'funnel_step' => '', + 'conversion_value' => 0.0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze user interactions. + * + * @param string $userId User ID (optional for aggregate analysis) + * @param string $timeframe Analysis timeframe + * @param array $filters Analysis filters + * @param array $metrics Metrics to analyze + * + * @return array{ + * success: bool, + * analysis: array{ + * user_id: string, + * timeframe: string, + * metrics: array{ + * total_interactions: int, + * unique_users: int, + * avg_session_duration: float, + * bounce_rate: float, + * conversion_rate: float, + * }, + * trends: array, + * top_events: array, + * user_segments: array, + * insights: array, + * recommendations: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function analyze( + string $userId = '', + string $timeframe = '7d', + array $filters = [], + array $metrics = ['interactions', 'engagement', 'conversion'], + ): array { + try { + $requestData = [ + 'user_id' => $userId, + 'timeframe' => $timeframe, + 'filters' => $filters, + 'metrics' => $metrics, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/analyze", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $analysis = $responseData['analysis'] ?? []; + + return [ + 'success' => true, + 'analysis' => [ + 'user_id' => $userId, + 'timeframe' => $timeframe, + 'metrics' => [ + 'total_interactions' => $analysis['metrics']['total_interactions'] ?? 0, + 'unique_users' => $analysis['metrics']['unique_users'] ?? 0, + 'avg_session_duration' => $analysis['metrics']['avg_session_duration'] ?? 0.0, + 'bounce_rate' => $analysis['metrics']['bounce_rate'] ?? 0.0, + 'conversion_rate' => $analysis['metrics']['conversion_rate'] ?? 0.0, + ], + 'trends' => array_map(fn ($trend) => [ + 'date' => $trend['date'] ?? '', + 'interactions' => $trend['interactions'] ?? 0, + 'users' => $trend['users'] ?? 0, + 'conversion_rate' => $trend['conversion_rate'] ?? 0.0, + ], $analysis['trends'] ?? []), + 'top_events' => array_map(fn ($event) => [ + 'event_type' => $event['event_type'] ?? '', + 'count' => $event['count'] ?? 0, + 'percentage' => $event['percentage'] ?? 0.0, + ], $analysis['top_events'] ?? []), + 'user_segments' => array_map(fn ($segment) => [ + 'segment' => $segment['segment'] ?? '', + 'users' => $segment['users'] ?? 0, + 'avg_interactions' => $segment['avg_interactions'] ?? 0.0, + ], $analysis['user_segments'] ?? []), + 'insights' => $analysis['insights'] ?? [], + 'recommendations' => $analysis['recommendations'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'analysis' => [ + 'user_id' => $userId, + 'timeframe' => $timeframe, + 'metrics' => [ + 'total_interactions' => 0, + 'unique_users' => 0, + 'avg_session_duration' => 0.0, + 'bounce_rate' => 0.0, + 'conversion_rate' => 0.0, + ], + 'trends' => [], + 'top_events' => [], + 'user_segments' => [], + 'insights' => [], + 'recommendations' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create surveys. + * + * @param string $title Survey title + * @param string $description Survey description + * @param array, + * required: bool, + * }> $questions Survey questions + * @param array $settings Survey settings + * + * @return array{ + * success: bool, + * survey: array{ + * survey_id: string, + * title: string, + * description: string, + * questions: array, + * required: bool, + * }>, + * settings: array, + * status: string, + * created_at: string, + * responses_count: int, + * completion_rate: float, + * }, + * processingTime: float, + * error: string, + * } + */ + public function survey( + string $title, + string $description, + array $questions, + array $settings = [], + ): array { + try { + $requestData = [ + 'title' => $title, + 'description' => $description, + 'questions' => $questions, + 'settings' => $settings, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/surveys", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $survey = $responseData['survey'] ?? []; + + return [ + 'success' => true, + 'survey' => [ + 'survey_id' => $survey['survey_id'] ?? '', + 'title' => $title, + 'description' => $description, + 'questions' => array_map(fn ($question, $index) => [ + 'question_id' => $question['question_id'] ?? "q_{$index}", + 'question' => $question['question'], + 'type' => $question['type'], + 'options' => $question['options'] ?? [], + 'required' => $question['required'] ?? false, + ], $questions), + 'settings' => $settings, + 'status' => $survey['status'] ?? 'active', + 'created_at' => $survey['created_at'] ?? date('c'), + 'responses_count' => $survey['responses_count'] ?? 0, + 'completion_rate' => $survey['completion_rate'] ?? 0.0, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'survey' => [ + 'survey_id' => '', + 'title' => $title, + 'description' => $description, + 'questions' => array_map(fn ($question, $index) => [ + 'question_id' => "q_{$index}", + 'question' => $question['question'], + 'type' => $question['type'], + 'options' => $question['options'] ?? [], + 'required' => $question['required'] ?? false, + ], $questions), + 'settings' => $settings, + 'status' => 'failed', + 'created_at' => date('c'), + 'responses_count' => 0, + 'completion_rate' => 0.0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Collect feedback. + * + * @param string $userId User ID + * @param string $feedbackType Type of feedback + * @param string $content Feedback content + * @param array $metadata Feedback metadata + * @param int $rating Rating (1-5) + * + * @return array{ + * success: bool, + * feedback: array{ + * feedback_id: string, + * user_id: string, + * feedback_type: string, + * content: string, + * rating: int, + * metadata: array, + * timestamp: string, + * sentiment: array{ + * score: float, + * label: string, + * confidence: float, + * }, + * categories: array, + * priority: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function feedback( + string $userId, + string $feedbackType, + string $content, + array $metadata = [], + int $rating = 0, + ): array { + try { + $requestData = [ + 'user_id' => $userId, + 'feedback_type' => $feedbackType, + 'content' => $content, + 'metadata' => $metadata, + 'rating' => max(0, min($rating, 5)), + 'timestamp' => date('c'), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/feedback", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $feedback = $responseData['feedback'] ?? []; + + return [ + 'success' => true, + 'feedback' => [ + 'feedback_id' => $feedback['feedback_id'] ?? '', + 'user_id' => $userId, + 'feedback_type' => $feedbackType, + 'content' => $content, + 'rating' => $rating, + 'metadata' => $metadata, + 'timestamp' => $feedback['timestamp'] ?? date('c'), + 'sentiment' => [ + 'score' => $feedback['sentiment']['score'] ?? 0.0, + 'label' => $feedback['sentiment']['label'] ?? 'neutral', + 'confidence' => $feedback['sentiment']['confidence'] ?? 0.0, + ], + 'categories' => $feedback['categories'] ?? [], + 'priority' => $feedback['priority'] ?? 'medium', + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'feedback' => [ + 'feedback_id' => '', + 'user_id' => $userId, + 'feedback_type' => $feedbackType, + 'content' => $content, + 'rating' => $rating, + 'metadata' => $metadata, + 'timestamp' => date('c'), + 'sentiment' => [ + 'score' => 0.0, + 'label' => 'neutral', + 'confidence' => 0.0, + ], + 'categories' => [], + 'priority' => 'medium', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Measure engagement. + * + * @param string $userId User ID + * @param string $timeframe Engagement timeframe + * @param array $metrics Engagement metrics + * + * @return array{ + * success: bool, + * engagement: array{ + * user_id: string, + * timeframe: string, + * score: float, + * metrics: array{ + * session_count: int, + * avg_session_duration: float, + * page_views: int, + * interactions: int, + * return_visits: int, + * }, + * trends: array, + * benchmarks: array{ + * percentile: float, + * category: string, + * comparison: string, + * }, + * recommendations: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function engagement( + string $userId, + string $timeframe = '30d', + array $metrics = ['sessions', 'duration', 'interactions'], + ): array { + try { + $requestData = [ + 'user_id' => $userId, + 'timeframe' => $timeframe, + 'metrics' => $metrics, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/engagement", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $engagement = $responseData['engagement'] ?? []; + + return [ + 'success' => true, + 'engagement' => [ + 'user_id' => $userId, + 'timeframe' => $timeframe, + 'score' => $engagement['score'] ?? 0.0, + 'metrics' => [ + 'session_count' => $engagement['metrics']['session_count'] ?? 0, + 'avg_session_duration' => $engagement['metrics']['avg_session_duration'] ?? 0.0, + 'page_views' => $engagement['metrics']['page_views'] ?? 0, + 'interactions' => $engagement['metrics']['interactions'] ?? 0, + 'return_visits' => $engagement['metrics']['return_visits'] ?? 0, + ], + 'trends' => array_map(fn ($trend) => [ + 'date' => $trend['date'] ?? '', + 'score' => $trend['score'] ?? 0.0, + 'sessions' => $trend['sessions'] ?? 0, + 'duration' => $trend['duration'] ?? 0.0, + ], $engagement['trends'] ?? []), + 'benchmarks' => [ + 'percentile' => $engagement['benchmarks']['percentile'] ?? 0.0, + 'category' => $engagement['benchmarks']['category'] ?? 'average', + 'comparison' => $engagement['benchmarks']['comparison'] ?? '', + ], + 'recommendations' => $engagement['recommendations'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'engagement' => [ + 'user_id' => $userId, + 'timeframe' => $timeframe, + 'score' => 0.0, + 'metrics' => [ + 'session_count' => 0, + 'avg_session_duration' => 0.0, + 'page_views' => 0, + 'interactions' => 0, + 'return_visits' => 0, + ], + 'trends' => [], + 'benchmarks' => [ + 'percentile' => 0.0, + 'category' => 'average', + 'comparison' => '', + ], + 'recommendations' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze user behavior. + * + * @param string $userId User ID + * @param string $timeframe Analysis timeframe + * @param array $behaviorTypes Types of behavior to analyze + * + * @return array{ + * success: bool, + * behavior: array{ + * user_id: string, + * timeframe: string, + * patterns: array, + * sequences: array, + * frequency: int, + * probability: float, + * }>, + * anomalies: array, + * predictions: array, + * insights: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function behavior( + string $userId, + string $timeframe = '30d', + array $behaviorTypes = ['navigation', 'interaction', 'engagement'], + ): array { + try { + $requestData = [ + 'user_id' => $userId, + 'timeframe' => $timeframe, + 'behavior_types' => $behaviorTypes, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/behavior", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $behavior = $responseData['behavior'] ?? []; + + return [ + 'success' => true, + 'behavior' => [ + 'user_id' => $userId, + 'timeframe' => $timeframe, + 'patterns' => array_map(fn ($pattern) => [ + 'pattern_type' => $pattern['pattern_type'] ?? '', + 'description' => $pattern['description'] ?? '', + 'frequency' => $pattern['frequency'] ?? 0, + 'confidence' => $pattern['confidence'] ?? 0.0, + ], $behavior['patterns'] ?? []), + 'sequences' => array_map(fn ($sequence) => [ + 'sequence' => $sequence['sequence'] ?? [], + 'frequency' => $sequence['frequency'] ?? 0, + 'probability' => $sequence['probability'] ?? 0.0, + ], $behavior['sequences'] ?? []), + 'anomalies' => array_map(fn ($anomaly) => [ + 'type' => $anomaly['type'] ?? '', + 'description' => $anomaly['description'] ?? '', + 'severity' => $anomaly['severity'] ?? 'low', + 'timestamp' => $anomaly['timestamp'] ?? '', + ], $behavior['anomalies'] ?? []), + 'predictions' => array_map(fn ($prediction) => [ + 'event' => $prediction['event'] ?? '', + 'probability' => $prediction['probability'] ?? 0.0, + 'timeframe' => $prediction['timeframe'] ?? '', + ], $behavior['predictions'] ?? []), + 'insights' => $behavior['insights'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'behavior' => [ + 'user_id' => $userId, + 'timeframe' => $timeframe, + 'patterns' => [], + 'sequences' => [], + 'anomalies' => [], + 'predictions' => [], + 'insights' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Provide personalization. + * + * @param string $userId User ID + * @param string $context Personalization context + * @param array $preferences User preferences + * @param array $options Personalization options + * + * @return array{ + * success: bool, + * personalization: array{ + * user_id: string, + * context: string, + * recommendations: array, + * segments: array, + * preferences: array, + * next_best_action: array{ + * action: string, + * score: float, + * reasoning: string, + * }, + * content_variations: array, + * }>, + * }, + * processingTime: float, + * error: string, + * } + */ + public function personalization( + string $userId, + string $context, + array $preferences = [], + array $options = [], + ): array { + try { + $requestData = [ + 'user_id' => $userId, + 'context' => $context, + 'preferences' => $preferences, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/personalization", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $personalization = $responseData['personalization'] ?? []; + + return [ + 'success' => true, + 'personalization' => [ + 'user_id' => $userId, + 'context' => $context, + 'recommendations' => array_map(fn ($rec) => [ + 'type' => $rec['type'] ?? '', + 'content' => $rec['content'] ?? '', + 'score' => $rec['score'] ?? 0.0, + 'reason' => $rec['reason'] ?? '', + ], $personalization['recommendations'] ?? []), + 'segments' => $personalization['segments'] ?? [], + 'preferences' => $preferences, + 'next_best_action' => [ + 'action' => $personalization['next_best_action']['action'] ?? '', + 'score' => $personalization['next_best_action']['score'] ?? 0.0, + 'reasoning' => $personalization['next_best_action']['reasoning'] ?? '', + ], + 'content_variations' => array_map(fn ($variation) => [ + 'variation_id' => $variation['variation_id'] ?? '', + 'content' => $variation['content'] ?? '', + 'targeting' => $variation['targeting'] ?? [], + ], $personalization['content_variations'] ?? []), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'personalization' => [ + 'user_id' => $userId, + 'context' => $context, + 'recommendations' => [], + 'segments' => [], + 'preferences' => $preferences, + 'next_best_action' => [ + 'action' => '', + 'score' => 0.0, + 'reasoning' => '', + ], + 'content_variations' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Jenkins.php b/src/agent/src/Toolbox/Tool/Jenkins.php new file mode 100644 index 000000000..61ca7d21b --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Jenkins.php @@ -0,0 +1,722 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('jenkins_get_jobs', 'Tool that gets Jenkins jobs')] +#[AsTool('jenkins_get_builds', 'Tool that gets Jenkins builds', method: 'getBuilds')] +#[AsTool('jenkins_trigger_build', 'Tool that triggers Jenkins builds', method: 'triggerBuild')] +#[AsTool('jenkins_get_build_logs', 'Tool that gets Jenkins build logs', method: 'getBuildLogs')] +#[AsTool('jenkins_get_nodes', 'Tool that gets Jenkins nodes', method: 'getNodes')] +#[AsTool('jenkins_get_plugins', 'Tool that gets Jenkins plugins', method: 'getPlugins')] +final readonly class Jenkins +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $username, + #[\SensitiveParameter] private string $apiToken, + private string $baseUrl, + private array $options = [], + ) { + } + + /** + * Get Jenkins jobs. + * + * @param string $folder Folder path (optional) + * @param int $depth Depth level (1 = jobs only, 2 = jobs + folders) + * + * @return array, + * firstBuild: array{ + * _class: string, + * number: int, + * url: string, + * }|null, + * lastBuild: array{ + * _class: string, + * number: int, + * url: string, + * }|null, + * lastCompletedBuild: array{ + * _class: string, + * number: int, + * url: string, + * }|null, + * lastFailedBuild: array{ + * _class: string, + * number: int, + * url: string, + * }|null, + * lastStableBuild: array{ + * _class: string, + * number: int, + * url: string, + * }|null, + * lastSuccessfulBuild: array{ + * _class: string, + * number: int, + * url: string, + * }|null, + * lastUnstableBuild: array{ + * _class: string, + * number: int, + * url: string, + * }|null, + * lastUnsuccessfulBuild: array{ + * _class: string, + * number: int, + * url: string, + * }|null, + * nextBuildNumber: int, + * inQueue: bool, + * keepDependencies: bool, + * concurrentBuild: bool, + * resumeBlocked: bool, + * disabled: bool, + * upstreamProjects: array, + * downstreamProjects: array, + * }> + */ + public function __invoke( + string $folder = '', + int $depth = 1, + ): array { + try { + $params = [ + 'depth' => $depth, + ]; + + $url = $folder + ? "{$this->baseUrl}/job/{$folder}/api/json" + : "{$this->baseUrl}/api/json"; + + $headers = ['Content-Type' => 'application/json']; + if ($this->username && $this->apiToken) { + $headers['Authorization'] = 'Basic '.base64_encode($this->username.':'.$this->apiToken); + } + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + $jobs = $folder ? [$data] : ($data['jobs'] ?? []); + + return array_map(fn ($job) => [ + '_class' => $job['_class'] ?? '', + 'name' => $job['name'] ?? '', + 'url' => $job['url'] ?? '', + 'color' => $job['color'] ?? 'notbuilt', + 'description' => $job['description'] ?? '', + 'displayName' => $job['displayName'] ?? '', + 'displayNameOrNull' => $job['displayNameOrNull'] ?? '', + 'fullDisplayName' => $job['fullDisplayName'] ?? '', + 'fullName' => $job['fullName'] ?? '', + 'buildable' => $job['buildable'] ?? false, + 'builds' => array_map(fn ($build) => [ + '_class' => $build['_class'], + 'number' => $build['number'], + 'url' => $build['url'], + ], $job['builds'] ?? []), + 'firstBuild' => $job['firstBuild'] ? [ + '_class' => $job['firstBuild']['_class'], + 'number' => $job['firstBuild']['number'], + 'url' => $job['firstBuild']['url'], + ] : null, + 'lastBuild' => $job['lastBuild'] ? [ + '_class' => $job['lastBuild']['_class'], + 'number' => $job['lastBuild']['number'], + 'url' => $job['lastBuild']['url'], + ] : null, + 'lastCompletedBuild' => $job['lastCompletedBuild'] ? [ + '_class' => $job['lastCompletedBuild']['_class'], + 'number' => $job['lastCompletedBuild']['number'], + 'url' => $job['lastCompletedBuild']['url'], + ] : null, + 'lastFailedBuild' => $job['lastFailedBuild'] ? [ + '_class' => $job['lastFailedBuild']['_class'], + 'number' => $job['lastFailedBuild']['number'], + 'url' => $job['lastFailedBuild']['url'], + ] : null, + 'lastStableBuild' => $job['lastStableBuild'] ? [ + '_class' => $job['lastStableBuild']['_class'], + 'number' => $job['lastStableBuild']['number'], + 'url' => $job['lastStableBuild']['url'], + ] : null, + 'lastSuccessfulBuild' => $job['lastSuccessfulBuild'] ? [ + '_class' => $job['lastSuccessfulBuild']['_class'], + 'number' => $job['lastSuccessfulBuild']['number'], + 'url' => $job['lastSuccessfulBuild']['url'], + ] : null, + 'lastUnstableBuild' => $job['lastUnstableBuild'] ? [ + '_class' => $job['lastUnstableBuild']['_class'], + 'number' => $job['lastUnstableBuild']['number'], + 'url' => $job['lastUnstableBuild']['url'], + ] : null, + 'lastUnsuccessfulBuild' => $job['lastUnsuccessfulBuild'] ? [ + '_class' => $job['lastUnsuccessfulBuild']['_class'], + 'number' => $job['lastUnsuccessfulBuild']['number'], + 'url' => $job['lastUnsuccessfulBuild']['url'], + ] : null, + 'nextBuildNumber' => $job['nextBuildNumber'] ?? 1, + 'inQueue' => $job['inQueue'] ?? false, + 'keepDependencies' => $job['keepDependencies'] ?? false, + 'concurrentBuild' => $job['concurrentBuild'] ?? false, + 'resumeBlocked' => $job['resumeBlocked'] ?? false, + 'disabled' => $job['disabled'] ?? false, + 'upstreamProjects' => array_map(fn ($project) => [ + '_class' => $project['_class'], + 'name' => $project['name'], + 'url' => $project['url'], + 'color' => $project['color'], + ], $job['upstreamProjects'] ?? []), + 'downstreamProjects' => array_map(fn ($project) => [ + '_class' => $project['_class'], + 'name' => $project['name'], + 'url' => $project['url'], + 'color' => $project['color'], + ], $job['downstreamProjects'] ?? []), + ], $jobs); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Jenkins builds. + * + * @param string $jobName Job name + * @param int $limit Number of builds to retrieve + * + * @return array, + * commitId: string, + * timestamp: int, + * msg: string, + * author: array{ + * absoluteUrl: string, + * fullName: string, + * }, + * authorEmail: string, + * comment: string, + * date: string, + * id: string, + * msg: string, + * paths: array, + * revision: int, + * user: string, + * }>, + * kind: string, + * revisions: array, + * }, + * actions: array>, + * artifacts: array, + * building: bool, + * description: string, + * displayName: string, + * duration: int, + * estimatedDuration: int, + * executor: string|null, + * fullDisplayName: string, + * id: string, + * keepLog: bool, + * number: int, + * queueId: int, + * result: string|null, + * timestamp: int, + * url: string, + * builtOn: string, + * changeSet: array{ + * _class: string, + * items: array, + * kind: string, + * revisions: array, + * }, + * culprits: array, + * }> + */ + public function getBuilds( + string $jobName, + int $limit = 10, + ): array { + try { + $params = [ + 'tree' => 'builds[number,url,displayName,result,duration,building,timestamp,changeSet[*]]', + ]; + + $headers = ['Content-Type' => 'application/json']; + if ($this->username && $this->apiToken) { + $headers['Authorization'] = 'Basic '.base64_encode($this->username.':'.$this->apiToken); + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/job/{$jobName}/api/json", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + $builds = \array_slice($data['builds'] ?? [], 0, $limit); + + return array_map(fn ($build) => [ + '_class' => $build['_class'] ?? '', + 'number' => $build['number'], + 'url' => $build['url'], + 'displayName' => $build['displayName'] ?? '', + 'fullDisplayName' => $build['fullDisplayName'] ?? '', + 'description' => $build['description'] ?? '', + 'result' => $build['result'], + 'duration' => $build['duration'] ?? 0, + 'estimatedDuration' => $build['estimatedDuration'] ?? 0, + 'building' => $build['building'] ?? false, + 'timestamp' => $build['timestamp'] ?? 0, + 'changeSet' => [ + '_class' => $build['changeSet']['_class'] ?? '', + 'items' => array_map(fn ($item) => [ + '_class' => $item['_class'] ?? '', + 'affectedPaths' => $item['affectedPaths'] ?? [], + 'commitId' => $item['commitId'] ?? '', + 'timestamp' => $item['timestamp'] ?? 0, + 'msg' => $item['msg'] ?? '', + 'author' => [ + 'absoluteUrl' => $item['author']['absoluteUrl'] ?? '', + 'fullName' => $item['author']['fullName'] ?? '', + ], + 'authorEmail' => $item['authorEmail'] ?? '', + 'comment' => $item['comment'] ?? '', + 'date' => $item['date'] ?? '', + 'id' => $item['id'] ?? '', + 'paths' => array_map(fn ($path) => [ + 'editType' => $path['editType'] ?? '', + 'file' => $path['file'] ?? '', + ], $item['paths'] ?? []), + 'revision' => $item['revision'] ?? 0, + 'user' => $item['user'] ?? '', + ], $build['changeSet']['items'] ?? []), + 'kind' => $build['changeSet']['kind'] ?? '', + 'revisions' => array_map(fn ($revision) => [ + 'module' => $revision['module'] ?? '', + 'revision' => $revision['revision'] ?? 0, + ], $build['changeSet']['revisions'] ?? []), + ], + 'actions' => $build['actions'] ?? [], + 'artifacts' => array_map(fn ($artifact) => [ + 'displayPath' => $artifact['displayPath'] ?? '', + 'fileName' => $artifact['fileName'] ?? '', + 'relativePath' => $artifact['relativePath'] ?? '', + ], $build['artifacts'] ?? []), + 'executor' => $build['executor'] ?? null, + 'id' => $build['id'] ?? '', + 'keepLog' => $build['keepLog'] ?? false, + 'queueId' => $build['queueId'] ?? 0, + 'builtOn' => $build['builtOn'] ?? '', + 'culprits' => array_map(fn ($culprit) => [ + 'absoluteUrl' => $culprit['absoluteUrl'] ?? '', + 'fullName' => $culprit['fullName'] ?? '', + ], $build['culprits'] ?? []), + ], $builds); + } catch (\Exception $e) { + return []; + } + } + + /** + * Trigger Jenkins build. + * + * @param string $jobName Job name + * @param array $parameters Build parameters + */ + public function triggerBuild( + string $jobName, + array $parameters = [], + ): string { + try { + $headers = ['Content-Type' => 'application/x-www-form-urlencoded']; + if ($this->username && $this->apiToken) { + $headers['Authorization'] = 'Basic '.base64_encode($this->username.':'.$this->apiToken); + } + + $body = ''; + if (!empty($parameters)) { + $body = http_build_query(['json' => json_encode(['parameter' => array_map(fn ($key, $value) => ['name' => $key, 'value' => $value], array_keys($parameters), $parameters)])]); + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/job/{$jobName}/build", [ + 'headers' => $headers, + 'body' => $body, + ]); + + if (201 === $response->getStatusCode() || 200 === $response->getStatusCode()) { + return 'Build triggered successfully'; + } + + return 'Error triggering build: HTTP '.$response->getStatusCode(); + } catch (\Exception $e) { + return 'Error triggering build: '.$e->getMessage(); + } + } + + /** + * Get Jenkins build logs. + * + * @param string $jobName Job name + * @param int $buildNumber Build number + * @param int $start Start line number + * @param bool $progressive Progressive output + * + * @return array{ + * logs: string, + * truncated: bool, + * moreData: bool, + * }|string + */ + public function getBuildLogs( + string $jobName, + int $buildNumber, + int $start = 0, + bool $progressive = true, + ): array|string { + try { + $params = [ + 'start' => $start, + ]; + + if ($progressive) { + $params['progressive'] = 'true'; + } + + $headers = ['Content-Type' => 'text/plain']; + if ($this->username && $this->apiToken) { + $headers['Authorization'] = 'Basic '.base64_encode($this->username.':'.$this->apiToken); + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/job/{$jobName}/{$buildNumber}/consoleText", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + if (200 === $response->getStatusCode()) { + $logs = $response->getContent(); + + return [ + 'logs' => $logs, + 'truncated' => false, + 'moreData' => false, + ]; + } + + return 'Error getting build logs: HTTP '.$response->getStatusCode(); + } catch (\Exception $e) { + return 'Error getting build logs: '.$e->getMessage(); + } + } + + /** + * Get Jenkins nodes. + * + * @return array, + * icon: string, + * iconClassName: string, + * idle: bool, + * jnlpAgent: bool, + * launchSupported: bool, + * manualLaunchAllowed: bool, + * monitorData: array{ + * 'hudson.node_monitors.ArchitectureMonitor': string, + * 'hudson.node_monitors.ClockMonitor': array{ + * diff: int, + * }, + * 'hudson.node_monitors.DiskSpaceMonitor': array{ + * path: string, + * size: int, + * timestamp: int, + * }, + * 'hudson.node_monitors.ResponseTimeMonitor': array{ + * average: int, + * timestamp: int, + * }, + * 'hudson.node_monitors.SwapSpaceMonitor': array{ + * availablePhysicalMemory: int, + * availableSwapSpace: int, + * totalPhysicalMemory: int, + * totalSwapSpace: int, + * }, + * 'hudson.node_monitors.TemporarySpaceMonitor': array{ + * path: string, + * size: int, + * timestamp: int, + * }, + * }, + * numExecutors: int, + * offline: bool, + * offlineCause: array{ + * _class: string, + * description: string, + * }|null, + * offlineCauseReason: string, + * temporarilyOffline: bool, + * absoluteRemotePath: string, + * description: string, + * labelString: string, + * mode: string, + * nodeDescription: string, + * nodeName: string, + * nodeProperties: array{ + * _class: string, + * }, + * slaveAgentPort: int, + * url: string, + * }> + */ + public function getNodes(): array + { + try { + $headers = ['Content-Type' => 'application/json']; + if ($this->username && $this->apiToken) { + $headers['Authorization'] = 'Basic '.base64_encode($this->username.':'.$this->apiToken); + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/computer/api/json", [ + 'headers' => $headers, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($node) => [ + '_class' => $node['_class'] ?? '', + 'displayName' => $node['displayName'] ?? '', + 'executors' => array_map(fn ($executor) => [ + 'currentExecutable' => $executor['currentExecutable'] ? [ + '_class' => $executor['currentExecutable']['_class'], + 'number' => $executor['currentExecutable']['number'], + 'url' => $executor['currentExecutable']['url'], + ] : null, + 'idle' => $executor['idle'] ?? false, + 'likelyStuck' => $executor['likelyStuck'] ?? false, + 'number' => $executor['number'] ?? 0, + ], $node['executors'] ?? []), + 'icon' => $node['icon'] ?? '', + 'iconClassName' => $node['iconClassName'] ?? '', + 'idle' => $node['idle'] ?? false, + 'jnlpAgent' => $node['jnlpAgent'] ?? false, + 'launchSupported' => $node['launchSupported'] ?? false, + 'manualLaunchAllowed' => $node['manualLaunchAllowed'] ?? false, + 'monitorData' => [ + 'hudson.node_monitors.ArchitectureMonitor' => $node['monitorData']['hudson.node_monitors.ArchitectureMonitor'] ?? '', + 'hudson.node_monitors.ClockMonitor' => [ + 'diff' => $node['monitorData']['hudson.node_monitors.ClockMonitor']['diff'] ?? 0, + ], + 'hudson.node_monitors.DiskSpaceMonitor' => [ + 'path' => $node['monitorData']['hudson.node_monitors.DiskSpaceMonitor']['path'] ?? '', + 'size' => $node['monitorData']['hudson.node_monitors.DiskSpaceMonitor']['size'] ?? 0, + 'timestamp' => $node['monitorData']['hudson.node_monitors.DiskSpaceMonitor']['timestamp'] ?? 0, + ], + 'hudson.node_monitors.ResponseTimeMonitor' => [ + 'average' => $node['monitorData']['hudson.node_monitors.ResponseTimeMonitor']['average'] ?? 0, + 'timestamp' => $node['monitorData']['hudson.node_monitors.ResponseTimeMonitor']['timestamp'] ?? 0, + ], + 'hudson.node_monitors.SwapSpaceMonitor' => [ + 'availablePhysicalMemory' => $node['monitorData']['hudson.node_monitors.SwapSpaceMonitor']['availablePhysicalMemory'] ?? 0, + 'availableSwapSpace' => $node['monitorData']['hudson.node_monitors.SwapSpaceMonitor']['availableSwapSpace'] ?? 0, + 'totalPhysicalMemory' => $node['monitorData']['hudson.node_monitors.SwapSpaceMonitor']['totalPhysicalMemory'] ?? 0, + 'totalSwapSpace' => $node['monitorData']['hudson.node_monitors.SwapSpaceMonitor']['totalSwapSpace'] ?? 0, + ], + 'hudson.node_monitors.TemporarySpaceMonitor' => [ + 'path' => $node['monitorData']['hudson.node_monitors.TemporarySpaceMonitor']['path'] ?? '', + 'size' => $node['monitorData']['hudson.node_monitors.TemporarySpaceMonitor']['size'] ?? 0, + 'timestamp' => $node['monitorData']['hudson.node_monitors.TemporarySpaceMonitor']['timestamp'] ?? 0, + ], + ], + 'numExecutors' => $node['numExecutors'] ?? 0, + 'offline' => $node['offline'] ?? false, + 'offlineCause' => $node['offlineCause'] ? [ + '_class' => $node['offlineCause']['_class'], + 'description' => $node['offlineCause']['description'], + ] : null, + 'offlineCauseReason' => $node['offlineCauseReason'] ?? '', + 'temporarilyOffline' => $node['temporarilyOffline'] ?? false, + 'absoluteRemotePath' => $node['absoluteRemotePath'] ?? '', + 'description' => $node['description'] ?? '', + 'labelString' => $node['labelString'] ?? '', + 'mode' => $node['mode'] ?? 'NORMAL', + 'nodeDescription' => $node['nodeDescription'] ?? '', + 'nodeName' => $node['nodeName'] ?? '', + 'nodeProperties' => [ + '_class' => $node['nodeProperties']['_class'] ?? '', + ], + 'slaveAgentPort' => $node['slaveAgentPort'] ?? 0, + 'url' => $node['url'] ?? '', + ], $data['computer'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Jenkins plugins. + * + * @return array, + * downgradable: bool, + * enabled: bool, + * hasUpdate: bool, + * longName: string, + * pinned: bool, + * requiredDependency: bool, + * shortName: string, + * supportsDynamicLoad: string, + * url: string, + * version: string, + * }> + */ + public function getPlugins(): array + { + try { + $headers = ['Content-Type' => 'application/json']; + if ($this->username && $this->apiToken) { + $headers['Authorization'] = 'Basic '.base64_encode($this->username.':'.$this->apiToken); + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/pluginManager/api/json", [ + 'headers' => $headers, + 'query' => ['depth' => 1], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($plugin) => [ + 'active' => $plugin['active'] ?? false, + 'backupVersion' => $plugin['backupVersion'] ?? null, + 'bundled' => $plugin['bundled'] ?? false, + 'deleted' => $plugin['deleted'] ?? false, + 'dependencies' => array_map(fn ($dep) => [ + 'name' => $dep['name'], + 'optional' => $dep['optional'] ?? false, + 'title' => $dep['title'] ?? '', + 'version' => $dep['version'], + ], $plugin['dependencies'] ?? []), + 'downgradable' => $plugin['downgradable'] ?? false, + 'enabled' => $plugin['enabled'] ?? false, + 'hasUpdate' => $plugin['hasUpdate'] ?? false, + 'longName' => $plugin['longName'] ?? '', + 'pinned' => $plugin['pinned'] ?? false, + 'requiredDependency' => $plugin['requiredDependency'] ?? false, + 'shortName' => $plugin['shortName'], + 'supportsDynamicLoad' => $plugin['supportsDynamicLoad'] ?? 'MAYBE', + 'url' => $plugin['url'] ?? '', + 'version' => $plugin['version'], + ], $data['plugins'] ?? []); + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/JinaSearch.php b/src/agent/src/Toolbox/Tool/JinaSearch.php new file mode 100644 index 000000000..f7f21c1e9 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/JinaSearch.php @@ -0,0 +1,485 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('jina_search', 'Tool that searches using Jina AI search engine')] +#[AsTool('jina_search_images', 'Tool that searches images using Jina', method: 'searchImages')] +#[AsTool('jina_search_videos', 'Tool that searches videos using Jina', method: 'searchVideos')] +#[AsTool('jina_search_news', 'Tool that searches news using Jina', method: 'searchNews')] +#[AsTool('jina_search_academic', 'Tool that searches academic papers using Jina', method: 'searchAcademic')] +final readonly class JinaSearch +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.jina.ai', + private array $options = [], + ) { + } + + /** + * Search using Jina AI search engine. + * + * @param string $query Search query + * @param string $category Search category (general, images, videos, news, academic) + * @param int $limit Number of results to return + * @param string $language Response language + * @param string $region Search region + * @param array $filters Additional filters + * + * @return array{ + * success: bool, + * results: array, + * totalResults: int, + * searchTime: float, + * query: string, + * error: string, + * } + */ + public function __invoke( + string $query, + string $category = 'general', + int $limit = 10, + string $language = 'en', + string $region = 'us', + array $filters = [], + ): array { + try { + $startTime = microtime(true); + + $requestData = [ + 'query' => $query, + 'category' => $category, + 'limit' => max(1, min($limit, 50)), + 'language' => $language, + 'region' => $region, + 'filters' => $filters, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/search", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $searchTime = microtime(true) - $startTime; + + return [ + 'success' => true, + 'results' => array_map(fn ($result, $index) => [ + 'title' => $result['title'] ?? '', + 'url' => $result['url'] ?? '', + 'snippet' => $result['snippet'] ?? '', + 'imageUrl' => $result['image_url'] ?? '', + 'publishedDate' => $result['published_date'] ?? '', + 'domain' => $result['domain'] ?? '', + 'rank' => $index + 1, + 'score' => $result['score'] ?? 0.0, + ], $data['results'] ?? [], array_keys($data['results'] ?? [])), + 'totalResults' => $data['total_results'] ?? \count($data['results'] ?? []), + 'searchTime' => $searchTime, + 'query' => $query, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'results' => [], + 'totalResults' => 0, + 'searchTime' => 0.0, + 'query' => $query, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search images using Jina. + * + * @param string $query Image search query + * @param string $size Image size filter (small, medium, large, xlarge) + * @param string $color Color filter (color, grayscale, transparent) + * @param string $type Image type filter (photo, clipart, lineart, animated) + * @param string $license License filter (public, share, modify, modify_commercially) + * @param int $limit Number of results + * + * @return array{ + * success: bool, + * images: array, + * totalResults: int, + * searchTime: float, + * error: string, + * } + */ + public function searchImages( + string $query, + string $size = 'medium', + string $color = 'color', + string $type = 'photo', + string $license = 'public', + int $limit = 20, + ): array { + try { + $startTime = microtime(true); + + $requestData = [ + 'query' => $query, + 'category' => 'images', + 'filters' => [ + 'size' => $size, + 'color' => $color, + 'type' => $type, + 'license' => $license, + ], + 'limit' => max(1, min($limit, 100)), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/search", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $searchTime = microtime(true) - $startTime; + + return [ + 'success' => true, + 'images' => array_map(fn ($image) => [ + 'title' => $image['title'] ?? '', + 'url' => $image['url'] ?? '', + 'thumbnailUrl' => $image['thumbnail_url'] ?? '', + 'sourceUrl' => $image['source_url'] ?? '', + 'width' => $image['width'] ?? 0, + 'height' => $image['height'] ?? 0, + 'size' => $image['size'] ?? '', + 'format' => $image['format'] ?? '', + 'license' => $image['license'] ?? '', + 'context' => $image['context'] ?? '', + ], $data['results'] ?? []), + 'totalResults' => $data['total_results'] ?? \count($data['results'] ?? []), + 'searchTime' => $searchTime, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'images' => [], + 'totalResults' => 0, + 'searchTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search videos using Jina. + * + * @param string $query Video search query + * @param string $duration Duration filter (short, medium, long) + * @param string $quality Quality filter (hd, sd) + * @param string $license License filter (public, share, modify) + * @param int $limit Number of results + * + * @return array{ + * success: bool, + * videos: array, + * totalResults: int, + * searchTime: float, + * error: string, + * } + */ + public function searchVideos( + string $query, + string $duration = 'medium', + string $quality = 'hd', + string $license = 'public', + int $limit = 20, + ): array { + try { + $startTime = microtime(true); + + $requestData = [ + 'query' => $query, + 'category' => 'videos', + 'filters' => [ + 'duration' => $duration, + 'quality' => $quality, + 'license' => $license, + ], + 'limit' => max(1, min($limit, 100)), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/search", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $searchTime = microtime(true) - $startTime; + + return [ + 'success' => true, + 'videos' => array_map(fn ($video) => [ + 'title' => $video['title'] ?? '', + 'url' => $video['url'] ?? '', + 'thumbnailUrl' => $video['thumbnail_url'] ?? '', + 'duration' => $video['duration'] ?? 0, + 'views' => $video['views'] ?? 0, + 'uploadDate' => $video['upload_date'] ?? '', + 'channel' => $video['channel'] ?? '', + 'description' => $video['description'] ?? '', + 'quality' => $video['quality'] ?? '', + 'license' => $video['license'] ?? '', + ], $data['results'] ?? []), + 'totalResults' => $data['total_results'] ?? \count($data['results'] ?? []), + 'searchTime' => $searchTime, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'videos' => [], + 'totalResults' => 0, + 'searchTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search news using Jina. + * + * @param string $query News search query + * @param string $language News language + * @param string $region News region + * @param string $timeframe Time frame (1d, 7d, 30d, 1y, all) + * @param int $limit Number of results + * + * @return array{ + * success: bool, + * news: array, + * totalResults: int, + * searchTime: float, + * error: string, + * } + */ + public function searchNews( + string $query, + string $language = 'en', + string $region = 'us', + string $timeframe = '7d', + int $limit = 20, + ): array { + try { + $startTime = microtime(true); + + $requestData = [ + 'query' => $query, + 'category' => 'news', + 'filters' => [ + 'language' => $language, + 'region' => $region, + 'timeframe' => $timeframe, + ], + 'limit' => max(1, min($limit, 100)), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/search", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $searchTime = microtime(true) - $startTime; + + return [ + 'success' => true, + 'news' => array_map(fn ($article) => [ + 'title' => $article['title'] ?? '', + 'url' => $article['url'] ?? '', + 'snippet' => $article['snippet'] ?? '', + 'publishedDate' => $article['published_date'] ?? '', + 'source' => $article['source'] ?? '', + 'author' => $article['author'] ?? '', + 'category' => $article['category'] ?? '', + 'sentiment' => $article['sentiment'] ?? '', + 'imageUrl' => $article['image_url'] ?? '', + ], $data['results'] ?? []), + 'totalResults' => $data['total_results'] ?? \count($data['results'] ?? []), + 'searchTime' => $searchTime, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'news' => [], + 'totalResults' => 0, + 'searchTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search academic papers using Jina. + * + * @param string $query Academic search query + * @param string $field Research field + * @param string $year Publication year + * @param string $journal Journal name + * @param string $author Author name + * @param int $limit Number of results + * + * @return array{ + * success: bool, + * papers: array, + * journal: string, + * publicationDate: string, + * citations: int, + * doi: string, + * keywords: array, + * pdfUrl: string, + * }>, + * totalResults: int, + * searchTime: float, + * error: string, + * } + */ + public function searchAcademic( + string $query, + string $field = '', + string $year = '', + string $journal = '', + string $author = '', + int $limit = 20, + ): array { + try { + $startTime = microtime(true); + + $requestData = [ + 'query' => $query, + 'category' => 'academic', + 'filters' => [ + 'field' => $field, + 'year' => $year, + 'journal' => $journal, + 'author' => $author, + ], + 'limit' => max(1, min($limit, 100)), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/search", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $searchTime = microtime(true) - $startTime; + + return [ + 'success' => true, + 'papers' => array_map(fn ($paper) => [ + 'title' => $paper['title'] ?? '', + 'url' => $paper['url'] ?? '', + 'abstract' => $paper['abstract'] ?? '', + 'authors' => $paper['authors'] ?? [], + 'journal' => $paper['journal'] ?? '', + 'publicationDate' => $paper['publication_date'] ?? '', + 'citations' => $paper['citations'] ?? 0, + 'doi' => $paper['doi'] ?? '', + 'keywords' => $paper['keywords'] ?? [], + 'pdfUrl' => $paper['pdf_url'] ?? '', + ], $data['results'] ?? []), + 'totalResults' => $data['total_results'] ?? \count($data['results'] ?? []), + 'searchTime' => $searchTime, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'papers' => [], + 'totalResults' => 0, + 'searchTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Jira.php b/src/agent/src/Toolbox/Tool/Jira.php new file mode 100644 index 000000000..35815e8a7 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Jira.php @@ -0,0 +1,423 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('jira_search', 'Tool that searches Jira issues')] +#[AsTool('jira_create_issue', 'Tool that creates a new Jira issue', method: 'createIssue')] +#[AsTool('jira_update_issue', 'Tool that updates an existing Jira issue', method: 'updateIssue')] +#[AsTool('jira_get_issue', 'Tool that gets details of a specific Jira issue', method: 'getIssue')] +#[AsTool('jira_add_comment', 'Tool that adds a comment to a Jira issue', method: 'addComment')] +#[AsTool('jira_get_projects', 'Tool that gets list of Jira projects', method: 'getProjects')] +final readonly class Jira +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $baseUrl, + #[\SensitiveParameter] private string $username, + #[\SensitiveParameter] private string $apiToken, + private bool $isCloud = true, + private array $options = [], + ) { + } + + /** + * Search Jira issues. + * + * @param string $jql JQL (Jira Query Language) query + * @param int $maxResults Maximum number of results to return + * @param array $fields Fields to include in results + * + * @return array, + * url: string, + * }> + */ + public function __invoke( + #[With(maximum: 1000)] + string $jql, + int $maxResults = 50, + array $fields = ['summary', 'status', 'assignee', 'priority'], + ): array { + try { + $response = $this->httpClient->request('POST', $this->baseUrl.'/rest/api/3/search', [ + 'headers' => $this->getHeaders(), + 'json' => [ + 'jql' => $jql, + 'maxResults' => $maxResults, + 'fields' => $fields, + ], + ]); + + $data = $response->toArray(); + + if (!isset($data['issues'])) { + return []; + } + + $results = []; + foreach ($data['issues'] as $issue) { + $fields = $issue['fields']; + + $results[] = [ + 'key' => $issue['key'], + 'summary' => $fields['summary'] ?? '', + 'description' => $this->extractDescription($fields['description'] ?? null), + 'status' => $fields['status']['name'] ?? '', + 'priority' => $fields['priority']['name'] ?? '', + 'assignee' => $fields['assignee']['displayName'] ?? 'Unassigned', + 'reporter' => $fields['reporter']['displayName'] ?? '', + 'created' => $fields['created'] ?? '', + 'updated' => $fields['updated'] ?? '', + 'issue_type' => $fields['issuetype']['name'] ?? '', + 'project' => $fields['project']['name'] ?? '', + 'labels' => $fields['labels'] ?? [], + 'url' => $this->baseUrl.'/browse/'.$issue['key'], + ]; + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'key' => 'ERROR', + 'summary' => 'Search Error', + 'description' => 'Unable to search Jira issues: '.$e->getMessage(), + 'status' => '', + 'priority' => '', + 'assignee' => '', + 'reporter' => '', + 'created' => '', + 'updated' => '', + 'issue_type' => '', + 'project' => '', + 'labels' => [], + 'url' => '', + ], + ]; + } + } + + /** + * Create a new Jira issue. + * + * @param string $projectKey Project key (e.g., 'PROJ') + * @param string $issueType Issue type (e.g., 'Task', 'Bug', 'Story') + * @param string $summary Issue summary/title + * @param string $description Issue description + * @param string $assignee Assignee username (optional) + * @param string $priority Priority (e.g., 'High', 'Medium', 'Low') + * @param array $labels Issue labels + * + * @return array{ + * key: string, + * id: string, + * url: string, + * summary: string, + * }|string + */ + public function createIssue( + string $projectKey, + string $issueType, + string $summary, + string $description = '', + string $assignee = '', + string $priority = '', + array $labels = [], + ): array|string { + try { + $issueData = [ + 'fields' => [ + 'project' => ['key' => $projectKey], + 'summary' => $summary, + 'issuetype' => ['name' => $issueType], + ], + ]; + + if ($description) { + $issueData['fields']['description'] = [ + 'type' => 'doc', + 'version' => 1, + 'content' => [ + [ + 'type' => 'paragraph', + 'content' => [ + [ + 'type' => 'text', + 'text' => $description, + ], + ], + ], + ], + ]; + } + + if ($assignee) { + $issueData['fields']['assignee'] = ['name' => $assignee]; + } + + if ($priority) { + $issueData['fields']['priority'] = ['name' => $priority]; + } + + if (!empty($labels)) { + $issueData['fields']['labels'] = $labels; + } + + $response = $this->httpClient->request('POST', $this->baseUrl.'/rest/api/3/issue', [ + 'headers' => $this->getHeaders(), + 'json' => $issueData, + ]); + + $data = $response->toArray(); + + return [ + 'key' => $data['key'], + 'id' => $data['id'], + 'url' => $this->baseUrl.'/browse/'.$data['key'], + 'summary' => $summary, + ]; + } catch (\Exception $e) { + return 'Error creating issue: '.$e->getMessage(); + } + } + + /** + * Update an existing Jira issue. + * + * @param string $issueKey Issue key (e.g., 'PROJ-123') + * @param array $updates Fields to update + */ + public function updateIssue(string $issueKey, array $updates): string + { + try { + $response = $this->httpClient->request('PUT', $this->baseUrl."/rest/api/3/issue/{$issueKey}", [ + 'headers' => $this->getHeaders(), + 'json' => [ + 'fields' => $updates, + ], + ]); + + if (204 === $response->getStatusCode()) { + return "Issue {$issueKey} updated successfully"; + } else { + return "Failed to update issue {$issueKey}"; + } + } catch (\Exception $e) { + return 'Error updating issue: '.$e->getMessage(); + } + } + + /** + * Get details of a specific Jira issue. + * + * @param string $issueKey Issue key (e.g., 'PROJ-123') + * + * @return array|string + */ + public function getIssue(string $issueKey): array|string + { + try { + $response = $this->httpClient->request('GET', $this->baseUrl."/rest/api/3/issue/{$issueKey}", [ + 'headers' => $this->getHeaders(), + 'query' => [ + 'expand' => 'renderedFields,changelog', + ], + ]); + + return $response->toArray(); + } catch (\Exception $e) { + return 'Error getting issue: '.$e->getMessage(); + } + } + + /** + * Add a comment to a Jira issue. + * + * @param string $issueKey Issue key (e.g., 'PROJ-123') + * @param string $comment Comment text + * + * @return array{ + * id: string, + * body: string, + * created: string, + * author: string, + * }|string + */ + public function addComment(string $issueKey, string $comment): array|string + { + try { + $response = $this->httpClient->request('POST', $this->baseUrl."/rest/api/3/issue/{$issueKey}/comment", [ + 'headers' => $this->getHeaders(), + 'json' => [ + 'body' => [ + 'type' => 'doc', + 'version' => 1, + 'content' => [ + [ + 'type' => 'paragraph', + 'content' => [ + [ + 'type' => 'text', + 'text' => $comment, + ], + ], + ], + ], + ], + ], + ]); + + $data = $response->toArray(); + + return [ + 'id' => $data['id'], + 'body' => $comment, + 'created' => $data['created'], + 'author' => $data['author']['displayName'], + ]; + } catch (\Exception $e) { + return 'Error adding comment: '.$e->getMessage(); + } + } + + /** + * Get list of Jira projects. + * + * @return array + */ + public function getProjects(): array + { + try { + $response = $this->httpClient->request('GET', $this->baseUrl.'/rest/api/3/project', [ + 'headers' => $this->getHeaders(), + ]); + + $data = $response->toArray(); + + $results = []; + foreach ($data as $project) { + $results[] = [ + 'key' => $project['key'], + 'name' => $project['name'], + 'description' => $project['description'] ?? '', + 'project_type' => $project['projectTypeKey'], + 'lead' => $project['lead']['displayName'], + 'url' => $this->baseUrl.'/browse/'.$project['key'], + ]; + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'key' => 'ERROR', + 'name' => 'Error', + 'description' => 'Unable to get projects: '.$e->getMessage(), + 'project_type' => '', + 'lead' => '', + 'url' => '', + ], + ]; + } + } + + /** + * Get authentication headers. + * + * @return array + */ + private function getHeaders(): array + { + $headers = [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + + if ($this->isCloud) { + $headers['Authorization'] = 'Basic '.base64_encode($this->username.':'.$this->apiToken); + } else { + $headers['Authorization'] = 'Bearer '.$this->apiToken; + } + + return $headers; + } + + /** + * Extract text description from Jira's rich text format. + */ + private function extractDescription(?array $description): string + { + if (!$description) { + return ''; + } + + if (isset($description['content'])) { + return $this->extractTextFromContent($description['content']); + } + + if (\is_string($description)) { + return $description; + } + + return ''; + } + + /** + * Extract plain text from Jira's content structure. + * + * @param array> $content + */ + private function extractTextFromContent(array $content): string + { + $text = ''; + + foreach ($content as $block) { + if (isset($block['content'])) { + foreach ($block['content'] as $item) { + if (isset($item['text'])) { + $text .= $item['text']; + } + } + } + } + + return $text; + } +} diff --git a/src/agent/src/Toolbox/Tool/JsonTools.php b/src/agent/src/Toolbox/Tool/JsonTools.php new file mode 100644 index 000000000..802bc8e05 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/JsonTools.php @@ -0,0 +1,272 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +/** + * @author Mathieu Ledru + */ +#[AsTool('json_parse', 'Parse JSON string and return decoded data')] +#[AsTool('json_encode', 'Encode PHP data structure to JSON string', method: 'encode')] +#[AsTool('json_validate', 'Validate if a string is valid JSON', method: 'validate')] +#[AsTool('json_get_keys', 'Get keys from a JSON object at a given path', method: 'getKeys')] +#[AsTool('json_get_value', 'Get value from JSON at a given path', method: 'getValue')] +#[AsTool('json_merge', 'Merge multiple JSON objects', method: 'merge')] +#[AsTool('json_search', 'Search for a value in JSON data', method: 'search')] +final readonly class JsonTools +{ + public function __construct( + private int $maxValueLength = 200, + ) { + } + + /** + * Parse JSON string and return decoded data. + * + * @param string $jsonString The JSON string to parse + * + * @return array|null + */ + public function __invoke(string $jsonString): ?array + { + try { + $decoded = json_decode($jsonString, true, 512, \JSON_THROW_ON_ERROR); + + return \is_array($decoded) ? $decoded : null; + } catch (\JsonException $e) { + return null; + } + } + + /** + * Encode PHP data structure to JSON string. + * + * @param mixed $data The data to encode + * @param int $flags JSON encoding flags + */ + public function encode(mixed $data, int $flags = \JSON_PRETTY_PRINT): string + { + try { + return json_encode($data, $flags | \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + return 'Error encoding JSON: '.$e->getMessage(); + } + } + + /** + * Validate if a string is valid JSON. + * + * @param string $jsonString The JSON string to validate + * + * @return array{valid: bool, error?: string} + */ + public function validate(string $jsonString): array + { + try { + json_decode($jsonString, true, 512, \JSON_THROW_ON_ERROR); + + return ['valid' => true]; + } catch (\JsonException $e) { + return [ + 'valid' => false, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get keys from a JSON object at a given path. + * + * @param string $jsonString The JSON string + * @param string $path The path to the object (e.g., "data.users") + * + * @return array|string + */ + public function getKeys(string $jsonString, string $path = ''): array|string + { + try { + $data = json_decode($jsonString, true, 512, \JSON_THROW_ON_ERROR); + + if (!\is_array($data)) { + return 'Root data is not an object'; + } + + $target = $this->getValueAtPath($data, $path); + + if (!\is_array($target)) { + return 'Value at path is not an object, get the value directly'; + } + + return array_keys($target); + } catch (\JsonException $e) { + return 'JSON parse error: '.$e->getMessage(); + } catch (\Exception $e) { + return 'Error: '.$e->getMessage(); + } + } + + /** + * Get value from JSON at a given path. + * + * @param string $path The path to the value (e.g., "data.users[0].name") + */ + public function getValue(string $jsonString, string $path = ''): mixed + { + try { + $data = json_decode($jsonString, true, 512, \JSON_THROW_ON_ERROR); + + if (empty($path)) { + return $data; + } + + $value = $this->getValueAtPath($data, $path); + + if (\is_array($value) && \count($value) > 10) { + return 'Value is a large array/object, should explore its keys directly'; + } + + $stringValue = json_encode($value, \JSON_PRETTY_PRINT); + + if (\strlen($stringValue) > $this->maxValueLength) { + return substr($stringValue, 0, $this->maxValueLength).'...'; + } + + return $value; + } catch (\JsonException $e) { + return 'JSON parse error: '.$e->getMessage(); + } catch (\Exception $e) { + return 'Error: '.$e->getMessage(); + } + } + + /** + * Merge multiple JSON objects. + * + * @param string ...$jsonStrings JSON strings to merge + */ + public function merge(string ...$jsonStrings): string + { + try { + $result = []; + + foreach ($jsonStrings as $jsonString) { + $data = json_decode($jsonString, true, 512, \JSON_THROW_ON_ERROR); + if (\is_array($data)) { + $result = array_merge_recursive($result, $data); + } + } + + return json_encode($result, \JSON_PRETTY_PRINT | \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + return 'Error merging JSON: '.$e->getMessage(); + } + } + + /** + * Search for a value in JSON data. + * + * @param string $jsonString The JSON string to search in + * @param string $searchTerm The term to search for + * @param bool $caseSensitive Whether the search should be case sensitive + * + * @return array + */ + public function search(string $jsonString, string $searchTerm, bool $caseSensitive = false): array + { + try { + $data = json_decode($jsonString, true, 512, \JSON_THROW_ON_ERROR); + $results = []; + + $this->searchRecursive($data, $searchTerm, '', $results, $caseSensitive); + + return $results; + } catch (\JsonException $e) { + return [['path' => 'error', 'value' => 'JSON parse error: '.$e->getMessage()]]; + } + } + + /** + * Get value at a specific path in the data structure. + */ + private function getValueAtPath(array $data, string $path): mixed + { + if (empty($path)) { + return $data; + } + + $keys = $this->parsePath($path); + $current = $data; + + foreach ($keys as $key) { + if (\is_array($current) && \array_key_exists($key, $current)) { + $current = $current[$key]; + } else { + throw new \InvalidArgumentException("Path '{$path}' not found."); + } + } + + return $current; + } + + /** + * Parse a path string into an array of keys. + */ + private function parsePath(string $path): array + { + // Handle array notation like "users[0].name" + $pattern = '/\[([^\]]+)\]/'; + $path = preg_replace($pattern, '.$1', $path); + + $keys = explode('.', $path); + $result = []; + + foreach ($keys as $key) { + $key = trim($key); + if ('' === $key) { + continue; + } + + // Convert numeric strings to integers for array access + if (is_numeric($key)) { + $result[] = (int) $key; + } else { + $result[] = $key; + } + } + + return $result; + } + + /** + * Recursively search for a term in the data structure. + */ + private function searchRecursive(mixed $data, string $searchTerm, string $currentPath, array &$results, bool $caseSensitive): void + { + if (\is_array($data)) { + foreach ($data as $key => $value) { + $newPath = '' === $currentPath ? (string) $key : $currentPath.'.'.$key; + $this->searchRecursive($value, $searchTerm, $newPath, $results, $caseSensitive); + } + } elseif (\is_string($data)) { + $dataToSearch = $caseSensitive ? $data : strtolower($data); + $searchToFind = $caseSensitive ? $searchTerm : strtolower($searchTerm); + + if (str_contains($dataToSearch, $searchToFind)) { + $results[] = [ + 'path' => $currentPath, + 'value' => $data, + ]; + } + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Kibana.php b/src/agent/src/Toolbox/Tool/Kibana.php new file mode 100644 index 000000000..261d72ed6 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Kibana.php @@ -0,0 +1,537 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('kibana_get_saved_objects', 'Tool that gets Kibana saved objects')] +#[AsTool('kibana_search', 'Tool that searches Kibana data', method: 'search')] +#[AsTool('kibana_get_dashboards', 'Tool that gets Kibana dashboards', method: 'getDashboards')] +#[AsTool('kibana_get_visualizations', 'Tool that gets Kibana visualizations', method: 'getVisualizations')] +#[AsTool('kibana_get_index_patterns', 'Tool that gets Kibana index patterns', method: 'getIndexPatterns')] +#[AsTool('kibana_get_spaces', 'Tool that gets Kibana spaces', method: 'getSpaces')] +final readonly class Kibana +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $baseUrl, + private string $apiVersion = '7.17', + private array $options = [], + ) { + } + + /** + * Get Kibana saved objects. + * + * @param string $type Object type (dashboard, visualization, index-pattern, etc.) + * @param string $search Search query + * @param int $perPage Number of objects per page + * @param int $page Page number + * @param array $fields Fields to include in response + * @param string $sortField Field to sort by + * @param string $sortOrder Sort order (asc, desc) + * + * @return array, + * references: array, + * migrationVersion: array, + * coreMigrationVersion: string, + * namespaceType: string, + * managed: bool, + * namespaces: array, + * originId: string, + * }> + */ + public function __invoke( + string $type = '', + string $search = '', + int $perPage = 20, + int $page = 1, + array $fields = [], + string $sortField = 'updated_at', + string $sortOrder = 'desc', + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 10000), + 'page' => max($page, 1), + 'sort_field' => $sortField, + 'sort_order' => $sortOrder, + ]; + + if ($type) { + $params['type'] = $type; + } + if ($search) { + $params['search'] = $search; + } + if (!empty($fields)) { + $params['fields'] = implode(',', $fields); + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->apiKey) { + $headers['Authorization'] = 'ApiKey '.$this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/saved_objects/_find", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($object) => [ + 'id' => $object['id'], + 'type' => $object['type'], + 'updated_at' => $object['updated_at'], + 'version' => $object['version'], + 'attributes' => $object['attributes'], + 'references' => array_map(fn ($ref) => [ + 'id' => $ref['id'], + 'name' => $ref['name'], + 'type' => $ref['type'], + ], $object['references'] ?? []), + 'migrationVersion' => $object['migrationVersion'] ?? [], + 'coreMigrationVersion' => $object['coreMigrationVersion'] ?? '', + 'namespaceType' => $object['namespaceType'] ?? 'single', + 'managed' => $object['managed'] ?? false, + 'namespaces' => $object['namespaces'] ?? [], + 'originId' => $object['originId'] ?? '', + ], $data['saved_objects'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Search Kibana data. + * + * @param string $index Index pattern or index name + * @param array $query Search query DSL + * @param int $from Starting offset + * @param int $size Number of results to return + * @param array $sort Sort specification + * @param array $sourceFields Source fields to return + * @param array $aggregations Aggregations to perform + * + * @return array{ + * took: int, + * timed_out: bool, + * _shards: array{ + * total: int, + * successful: int, + * skipped: int, + * failed: int, + * }, + * hits: array{ + * total: array{ + * value: int, + * relation: string, + * }, + * max_score: float, + * hits: array, + * _version: int, + * _seq_no: int, + * _primary_term: int, + * }>, + * }, + * aggregations: array|null, + * }|string + */ + public function search( + string $index, + array $query = [], + int $from = 0, + int $size = 10, + array $sort = [], + array $sourceFields = [], + array $aggregations = [], + ): array|string { + try { + $searchBody = [ + 'from' => $from, + 'size' => min(max($size, 1), 10000), + ]; + + if (!empty($query)) { + $searchBody['query'] = $query; + } + if (!empty($sort)) { + $searchBody['sort'] = $sort; + } + if (!empty($sourceFields)) { + $searchBody['_source'] = $sourceFields; + } + if (!empty($aggregations)) { + $searchBody['aggs'] = $aggregations; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->apiKey) { + $headers['Authorization'] = 'ApiKey '.$this->apiKey; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/api/console/proxy", [ + 'headers' => $headers, + 'json' => [ + 'path' => "/{$index}/_search", + 'method' => 'POST', + 'body' => $searchBody, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error searching Kibana: '.($data['error']['reason'] ?? 'Unknown error'); + } + + return [ + 'took' => $data['took'], + 'timed_out' => $data['timed_out'], + '_shards' => [ + 'total' => $data['_shards']['total'], + 'successful' => $data['_shards']['successful'], + 'skipped' => $data['_shards']['skipped'], + 'failed' => $data['_shards']['failed'], + ], + 'hits' => [ + 'total' => [ + 'value' => $data['hits']['total']['value'] ?? $data['hits']['total'], + 'relation' => $data['hits']['total']['relation'] ?? 'eq', + ], + 'max_score' => $data['hits']['max_score'], + 'hits' => array_map(fn ($hit) => [ + '_index' => $hit['_index'], + '_type' => $hit['_type'] ?? '_doc', + '_id' => $hit['_id'], + '_score' => $hit['_score'], + '_source' => $hit['_source'], + '_version' => $hit['_version'] ?? 1, + '_seq_no' => $hit['_seq_no'] ?? 0, + '_primary_term' => $hit['_primary_term'] ?? 1, + ], $data['hits']['hits']), + ], + 'aggregations' => $data['aggregations'] ?? null, + ]; + } catch (\Exception $e) { + return 'Error searching Kibana: '.$e->getMessage(); + } + } + + /** + * Get Kibana dashboards. + * + * @param string $search Search query + * @param int $perPage Number of dashboards per page + * @param int $page Page number + * + * @return array, + * }> + */ + public function getDashboards( + string $search = '', + int $perPage = 20, + int $page = 1, + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 10000), + 'page' => max($page, 1), + ]; + + if ($search) { + $params['search'] = $search; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->apiKey) { + $headers['Authorization'] = 'ApiKey '.$this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/saved_objects/_find", [ + 'headers' => $headers, + 'query' => array_merge($this->options, array_merge($params, ['type' => 'dashboard'])), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($dashboard) => [ + 'id' => $dashboard['id'], + 'title' => $dashboard['attributes']['title'] ?? '', + 'description' => $dashboard['attributes']['description'] ?? '', + 'panelsJSON' => $dashboard['attributes']['panelsJSON'] ?? '', + 'optionsJSON' => $dashboard['attributes']['optionsJSON'] ?? '', + 'uiStateJSON' => $dashboard['attributes']['uiStateJSON'] ?? '', + 'version' => $dashboard['version'], + 'timeRestore' => $dashboard['attributes']['timeRestore'] ?? false, + 'kibanaSavedObjectMeta' => [ + 'searchSourceJSON' => $dashboard['attributes']['kibanaSavedObjectMeta']['searchSourceJSON'] ?? '', + ], + 'updated_at' => $dashboard['updated_at'], + 'references' => array_map(fn ($ref) => [ + 'id' => $ref['id'], + 'name' => $ref['name'], + 'type' => $ref['type'], + ], $dashboard['references'] ?? []), + ], $data['saved_objects'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Kibana visualizations. + * + * @param string $search Search query + * @param int $perPage Number of visualizations per page + * @param int $page Page number + * + * @return array, + * }> + */ + public function getVisualizations( + string $search = '', + int $perPage = 20, + int $page = 1, + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 10000), + 'page' => max($page, 1), + ]; + + if ($search) { + $params['search'] = $search; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->apiKey) { + $headers['Authorization'] = 'ApiKey '.$this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/saved_objects/_find", [ + 'headers' => $headers, + 'query' => array_merge($this->options, array_merge($params, ['type' => 'visualization'])), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($visualization) => [ + 'id' => $visualization['id'], + 'title' => $visualization['attributes']['title'] ?? '', + 'description' => $visualization['attributes']['description'] ?? '', + 'visState' => $visualization['attributes']['visState'] ?? '', + 'uiStateJSON' => $visualization['attributes']['uiStateJSON'] ?? '', + 'kibanaSavedObjectMeta' => [ + 'searchSourceJSON' => $visualization['attributes']['kibanaSavedObjectMeta']['searchSourceJSON'] ?? '', + ], + 'version' => $visualization['version'], + 'updated_at' => $visualization['updated_at'], + 'references' => array_map(fn ($ref) => [ + 'id' => $ref['id'], + 'name' => $ref['name'], + 'type' => $ref['type'], + ], $visualization['references'] ?? []), + ], $data['saved_objects'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Kibana index patterns. + * + * @param string $search Search query + * @param int $perPage Number of index patterns per page + * @param int $page Page number + * + * @return array, + * }> + */ + public function getIndexPatterns( + string $search = '', + int $perPage = 20, + int $page = 1, + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 10000), + 'page' => max($page, 1), + ]; + + if ($search) { + $params['search'] = $search; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->apiKey) { + $headers['Authorization'] = 'ApiKey '.$this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/saved_objects/_find", [ + 'headers' => $headers, + 'query' => array_merge($this->options, array_merge($params, ['type' => 'index-pattern'])), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($indexPattern) => [ + 'id' => $indexPattern['id'], + 'title' => $indexPattern['attributes']['title'] ?? '', + 'timeFieldName' => $indexPattern['attributes']['timeFieldName'] ?? '', + 'fields' => $indexPattern['attributes']['fields'] ?? '', + 'fieldFormatMap' => $indexPattern['attributes']['fieldFormatMap'] ?? '', + 'typeMeta' => $indexPattern['attributes']['typeMeta'] ?? '', + 'version' => $indexPattern['version'], + 'updated_at' => $indexPattern['updated_at'], + 'references' => array_map(fn ($ref) => [ + 'id' => $ref['id'], + 'name' => $ref['name'], + 'type' => $ref['type'], + ], $indexPattern['references'] ?? []), + ], $data['saved_objects'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Kibana spaces. + * + * @return array, + * imageUrl: string, + * _reserved: bool, + * }> + */ + public function getSpaces(): array + { + try { + $headers = ['Content-Type' => 'application/json']; + if ($this->apiKey) { + $headers['Authorization'] = 'ApiKey '.$this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/spaces/space", [ + 'headers' => $headers, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($space) => [ + 'id' => $space['id'], + 'name' => $space['name'], + 'description' => $space['description'] ?? '', + 'initials' => $space['initials'] ?? '', + 'color' => $space['color'] ?? '#000000', + 'disabledFeatures' => $space['disabledFeatures'] ?? [], + 'imageUrl' => $space['imageUrl'] ?? '', + '_reserved' => $space['_reserved'] ?? false, + ], $data); + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Kubernetes.php b/src/agent/src/Toolbox/Tool/Kubernetes.php new file mode 100644 index 000000000..ad516f0f4 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Kubernetes.php @@ -0,0 +1,416 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('k8s_get_pods', 'Tool that gets Kubernetes pods')] +#[AsTool('k8s_get_services', 'Tool that gets Kubernetes services', method: 'getServices')] +#[AsTool('k8s_get_deployments', 'Tool that gets Kubernetes deployments', method: 'getDeployments')] +#[AsTool('k8s_get_nodes', 'Tool that gets Kubernetes nodes', method: 'getNodes')] +#[AsTool('k8s_get_namespaces', 'Tool that gets Kubernetes namespaces', method: 'getNamespaces')] +#[AsTool('k8s_exec_command', 'Tool that executes commands in Kubernetes pods', method: 'execCommand')] +final readonly class Kubernetes +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $token, + private string $baseUrl, + private string $namespace = 'default', + private array $options = [], + ) { + } + + /** + * Get Kubernetes pods. + * + * @param string $namespace Namespace (default: default) + * @param string $labelSelector Label selector filter + * @param string $fieldSelector Field selector filter + * + * @return array, + * }>, + * labels: array, + * annotations: array, + * }> + */ + public function __invoke( + string $namespace = '', + string $labelSelector = '', + string $fieldSelector = '', + ): array { + try { + $ns = $namespace ?: $this->namespace; + $params = []; + + if ($labelSelector) { + $params['labelSelector'] = $labelSelector; + } + if ($fieldSelector) { + $params['fieldSelector'] = $fieldSelector; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->token) { + $headers['Authorization'] = 'Bearer '.$this->token; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/v1/namespaces/{$ns}/pods", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($pod) => [ + 'name' => $pod['metadata']['name'], + 'namespace' => $pod['metadata']['namespace'], + 'status' => $pod['status']['phase'], + 'phase' => $pod['status']['phase'], + 'podIP' => $pod['status']['podIP'] ?? '', + 'hostIP' => $pod['status']['hostIP'] ?? '', + 'startTime' => $pod['status']['startTime'] ?? '', + 'containers' => array_map(fn ($container) => [ + 'name' => $container['name'], + 'image' => $container['image'], + 'ready' => $container['ready'] ?? false, + 'restartCount' => $container['restartCount'] ?? 0, + 'state' => $container['state'] ?? [], + ], $pod['status']['containerStatuses'] ?? []), + 'labels' => $pod['metadata']['labels'] ?? [], + 'annotations' => $pod['metadata']['annotations'] ?? [], + ], $data['items'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Kubernetes services. + * + * @param string $namespace Namespace (default: default) + * @param string $labelSelector Label selector filter + * + * @return array, + * ports: array, + * selector: array, + * labels: array, + * }> + */ + public function getServices( + string $namespace = '', + string $labelSelector = '', + ): array { + try { + $ns = $namespace ?: $this->namespace; + $params = []; + + if ($labelSelector) { + $params['labelSelector'] = $labelSelector; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->token) { + $headers['Authorization'] = 'Bearer '.$this->token; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/v1/namespaces/{$ns}/services", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($service) => [ + 'name' => $service['metadata']['name'], + 'namespace' => $service['metadata']['namespace'], + 'type' => $service['spec']['type'], + 'clusterIP' => $service['spec']['clusterIP'] ?? '', + 'externalIPs' => $service['spec']['externalIPs'] ?? [], + 'ports' => array_map(fn ($port) => [ + 'name' => $port['name'] ?? '', + 'port' => $port['port'], + 'targetPort' => $port['targetPort'], + 'protocol' => $port['protocol'] ?? 'TCP', + ], $service['spec']['ports'] ?? []), + 'selector' => $service['spec']['selector'] ?? [], + 'labels' => $service['metadata']['labels'] ?? [], + ], $data['items'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Kubernetes deployments. + * + * @param string $namespace Namespace (default: default) + * @param string $labelSelector Label selector filter + * + * @return array, + * labels: array, + * }> + */ + public function getDeployments( + string $namespace = '', + string $labelSelector = '', + ): array { + try { + $ns = $namespace ?: $this->namespace; + $params = []; + + if ($labelSelector) { + $params['labelSelector'] = $labelSelector; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->token) { + $headers['Authorization'] = 'Bearer '.$this->token; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/apis/apps/v1/namespaces/{$ns}/deployments", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($deployment) => [ + 'name' => $deployment['metadata']['name'], + 'namespace' => $deployment['metadata']['namespace'], + 'replicas' => $deployment['spec']['replicas'], + 'readyReplicas' => $deployment['status']['readyReplicas'] ?? 0, + 'availableReplicas' => $deployment['status']['availableReplicas'] ?? 0, + 'strategy' => $deployment['spec']['strategy']['type'] ?? 'RollingUpdate', + 'selector' => $deployment['spec']['selector']['matchLabels'] ?? [], + 'labels' => $deployment['metadata']['labels'] ?? [], + ], $data['items'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Kubernetes nodes. + * + * @param string $labelSelector Label selector filter + * + * @return array, + * version: string, + * osImage: string, + * kernelVersion: string, + * containerRuntimeVersion: string, + * capacity: array, + * allocatable: array, + * labels: array, + * }> + */ + public function getNodes(string $labelSelector = ''): array + { + try { + $params = []; + + if ($labelSelector) { + $params['labelSelector'] = $labelSelector; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->token) { + $headers['Authorization'] = 'Bearer '.$this->token; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/v1/nodes", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($node) => [ + 'name' => $node['metadata']['name'], + 'status' => $node['status']['phase'], + 'roles' => array_keys(array_filter($node['metadata']['labels'] ?? [], fn ($key) => str_starts_with($key, 'node-role.kubernetes.io/'))), + 'version' => $node['status']['nodeInfo']['kubeletVersion'], + 'osImage' => $node['status']['nodeInfo']['osImage'], + 'kernelVersion' => $node['status']['nodeInfo']['kernelVersion'], + 'containerRuntimeVersion' => $node['status']['nodeInfo']['containerRuntimeVersion'], + 'capacity' => $node['status']['capacity'] ?? [], + 'allocatable' => $node['status']['allocatable'] ?? [], + 'labels' => $node['metadata']['labels'] ?? [], + ], $data['items'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Kubernetes namespaces. + * + * @param string $labelSelector Label selector filter + * + * @return array, + * annotations: array, + * }> + */ + public function getNamespaces(string $labelSelector = ''): array + { + try { + $params = []; + + if ($labelSelector) { + $params['labelSelector'] = $labelSelector; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->token) { + $headers['Authorization'] = 'Bearer '.$this->token; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/v1/namespaces", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($namespace) => [ + 'name' => $namespace['metadata']['name'], + 'status' => $namespace['status']['phase'], + 'labels' => $namespace['metadata']['labels'] ?? [], + 'annotations' => $namespace['metadata']['annotations'] ?? [], + ], $data['items'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Execute command in Kubernetes pod. + * + * @param string $podName Pod name + * @param string $container Container name (optional) + * @param string $command Command to execute + * @param string $namespace Namespace (default: default) + * + * @return array{ + * output: string, + * error: string, + * exitCode: int, + * }|string + */ + public function execCommand( + string $podName, + string $container = '', + string $command = '', + string $namespace = '', + ): array|string { + try { + $ns = $namespace ?: $this->namespace; + $params = [ + 'command' => explode(' ', $command), + 'stdin' => false, + 'stdout' => true, + 'stderr' => true, + 'tty' => false, + ]; + + if ($container) { + $params['container'] = $container; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->token) { + $headers['Authorization'] = 'Bearer '.$this->token; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/api/v1/namespaces/{$ns}/pods/{$podName}/exec", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + if (200 === $response->getStatusCode()) { + return [ + 'output' => $response->getContent(), + 'error' => '', + 'exitCode' => 0, + ]; + } + + return 'Error executing command: HTTP '.$response->getStatusCode(); + } catch (\Exception $e) { + return 'Error executing command: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Linear.php b/src/agent/src/Toolbox/Tool/Linear.php new file mode 100644 index 000000000..c329c413d --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Linear.php @@ -0,0 +1,804 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('linear_get_issues', 'Tool that gets Linear issues')] +#[AsTool('linear_create_issue', 'Tool that creates Linear issues', method: 'createIssue')] +#[AsTool('linear_update_issue', 'Tool that updates Linear issues', method: 'updateIssue')] +#[AsTool('linear_get_projects', 'Tool that gets Linear projects', method: 'getProjects')] +#[AsTool('linear_get_teams', 'Tool that gets Linear teams', method: 'getTeams')] +#[AsTool('linear_get_cycles', 'Tool that gets Linear cycles', method: 'getCycles')] +final readonly class Linear +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $apiVersion = '2023-10-16', + private array $options = [], + ) { + } + + /** + * Get Linear issues. + * + * @param string $teamId Team ID to filter issues + * @param string $projectId Project ID to filter issues + * @param string $state Issue state (triage, backlog, unstarted, started, completed, canceled) + * @param string $priority Issue priority (urgent, high, normal, low) + * @param string $assigneeId Assignee ID to filter issues + * @param int $first Number of issues to retrieve + * @param string $after Cursor for pagination + * + * @return array{ + * issues: array, + * createdAt: string, + * updatedAt: string, + * completedAt: string|null, + * url: string, + * }>, + * pageInfo: array{hasNextPage: bool, endCursor: string|null}, + * }|string + */ + public function __invoke( + string $teamId = '', + string $projectId = '', + string $state = '', + string $priority = '', + string $assigneeId = '', + int $first = 50, + string $after = '', + ): array|string { + try { + $query = $this->buildIssuesQuery($teamId, $projectId, $state, $priority, $assigneeId, $first, $after); + + $response = $this->httpClient->request('POST', 'https://api.linear.app/graphql', [ + 'headers' => [ + 'Authorization' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'query' => $query, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error getting issues: '.($data['errors'][0]['message'] ?? 'Unknown error'); + } + + $issues = $data['data']['issues']['nodes'] ?? []; + $pageInfo = $data['data']['issues']['pageInfo'] ?? ['hasNextPage' => false, 'endCursor' => null]; + + return [ + 'issues' => array_map(fn ($issue) => [ + 'id' => $issue['id'], + 'identifier' => $issue['identifier'], + 'title' => $issue['title'], + 'description' => $issue['description'] ?? '', + 'priority' => $issue['priority'], + 'estimate' => $issue['estimate'], + 'state' => [ + 'id' => $issue['state']['id'], + 'name' => $issue['state']['name'], + 'type' => $issue['state']['type'], + ], + 'team' => [ + 'id' => $issue['team']['id'], + 'name' => $issue['team']['name'], + 'key' => $issue['team']['key'], + ], + 'project' => $issue['project'] ? [ + 'id' => $issue['project']['id'], + 'name' => $issue['project']['name'], + ] : null, + 'assignee' => $issue['assignee'] ? [ + 'id' => $issue['assignee']['id'], + 'name' => $issue['assignee']['name'], + 'email' => $issue['assignee']['email'], + ] : null, + 'creator' => [ + 'id' => $issue['creator']['id'], + 'name' => $issue['creator']['name'], + 'email' => $issue['creator']['email'], + ], + 'labels' => array_map(fn ($label) => [ + 'id' => $label['id'], + 'name' => $label['name'], + 'color' => $label['color'], + ], $issue['labels']['nodes'] ?? []), + 'createdAt' => $issue['createdAt'], + 'updatedAt' => $issue['updatedAt'], + 'completedAt' => $issue['completedAt'], + 'url' => $issue['url'], + ], $issues), + 'pageInfo' => $pageInfo, + ]; + } catch (\Exception $e) { + return 'Error getting issues: '.$e->getMessage(); + } + } + + /** + * Create a Linear issue. + * + * @param string $teamId Team ID + * @param string $title Issue title + * @param string $description Issue description + * @param string $projectId Project ID (optional) + * @param string $assigneeId Assignee ID (optional) + * @param string $stateId State ID (optional) + * @param int $priority Priority (0=urgent, 1=high, 2=normal, 3=low) + * @param int $estimate Story points estimate (optional) + * @param array $labelIds Label IDs (optional) + * + * @return array{ + * id: string, + * identifier: string, + * title: string, + * description: string, + * priority: int, + * estimate: int|null, + * state: array{id: string, name: string, type: string}, + * team: array{id: string, name: string, key: string}, + * project: array{id: string, name: string}|null, + * assignee: array{id: string, name: string, email: string}|null, + * creator: array{id: string, name: string, email: string}, + * labels: array, + * createdAt: string, + * updatedAt: string, + * url: string, + * }|string + */ + public function createIssue( + string $teamId, + string $title, + string $description = '', + string $projectId = '', + string $assigneeId = '', + string $stateId = '', + int $priority = 2, + int $estimate = 0, + array $labelIds = [], + ): array|string { + try { + $mutation = $this->buildCreateIssueMutation($teamId, $title, $description, $projectId, $assigneeId, $stateId, $priority, $estimate, $labelIds); + + $response = $this->httpClient->request('POST', 'https://api.linear.app/graphql', [ + 'headers' => [ + 'Authorization' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'query' => $mutation, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error creating issue: '.($data['errors'][0]['message'] ?? 'Unknown error'); + } + + $issue = $data['data']['issueCreate']['issue']; + + return [ + 'id' => $issue['id'], + 'identifier' => $issue['identifier'], + 'title' => $issue['title'], + 'description' => $issue['description'] ?? '', + 'priority' => $issue['priority'], + 'estimate' => $issue['estimate'], + 'state' => [ + 'id' => $issue['state']['id'], + 'name' => $issue['state']['name'], + 'type' => $issue['state']['type'], + ], + 'team' => [ + 'id' => $issue['team']['id'], + 'name' => $issue['team']['name'], + 'key' => $issue['team']['key'], + ], + 'project' => $issue['project'] ? [ + 'id' => $issue['project']['id'], + 'name' => $issue['project']['name'], + ] : null, + 'assignee' => $issue['assignee'] ? [ + 'id' => $issue['assignee']['id'], + 'name' => $issue['assignee']['name'], + 'email' => $issue['assignee']['email'], + ] : null, + 'creator' => [ + 'id' => $issue['creator']['id'], + 'name' => $issue['creator']['name'], + 'email' => $issue['creator']['email'], + ], + 'labels' => array_map(fn ($label) => [ + 'id' => $label['id'], + 'name' => $label['name'], + 'color' => $label['color'], + ], $issue['labels']['nodes'] ?? []), + 'createdAt' => $issue['createdAt'], + 'updatedAt' => $issue['updatedAt'], + 'url' => $issue['url'], + ]; + } catch (\Exception $e) { + return 'Error creating issue: '.$e->getMessage(); + } + } + + /** + * Update a Linear issue. + * + * @param string $issueId Issue ID to update + * @param string $title New title (optional) + * @param string $description New description (optional) + * @param string $stateId New state ID (optional) + * @param string $assigneeId New assignee ID (optional) + * @param int $priority New priority (optional) + * @param int $estimate New estimate (optional) + */ + public function updateIssue( + string $issueId, + string $title = '', + string $description = '', + string $stateId = '', + string $assigneeId = '', + int $priority = -1, + int $estimate = -1, + ): string { + try { + $mutation = $this->buildUpdateIssueMutation($issueId, $title, $description, $stateId, $assigneeId, $priority, $estimate); + + $response = $this->httpClient->request('POST', 'https://api.linear.app/graphql', [ + 'headers' => [ + 'Authorization' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'query' => $mutation, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error updating issue: '.($data['errors'][0]['message'] ?? 'Unknown error'); + } + + return 'Issue updated successfully'; + } catch (\Exception $e) { + return 'Error updating issue: '.$e->getMessage(); + } + } + + /** + * Get Linear projects. + * + * @param string $teamId Team ID to filter projects + * @param int $first Number of projects to retrieve + * + * @return array{ + * projects: array, + * }|string + */ + public function getProjects( + string $teamId = '', + int $first = 50, + ): array|string { + try { + $query = $this->buildProjectsQuery($teamId, $first); + + $response = $this->httpClient->request('POST', 'https://api.linear.app/graphql', [ + 'headers' => [ + 'Authorization' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'query' => $query, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error getting projects: '.($data['errors'][0]['message'] ?? 'Unknown error'); + } + + $projects = $data['data']['projects']['nodes'] ?? []; + + return [ + 'projects' => array_map(fn ($project) => [ + 'id' => $project['id'], + 'name' => $project['name'], + 'description' => $project['description'] ?? '', + 'state' => $project['state'], + 'progress' => $project['progress'], + 'startDate' => $project['startDate'], + 'targetDate' => $project['targetDate'], + 'team' => [ + 'id' => $project['team']['id'], + 'name' => $project['team']['name'], + 'key' => $project['team']['key'], + ], + 'creator' => [ + 'id' => $project['creator']['id'], + 'name' => $project['creator']['name'], + 'email' => $project['creator']['email'], + ], + 'createdAt' => $project['createdAt'], + 'updatedAt' => $project['updatedAt'], + 'url' => $project['url'], + ], $projects), + ]; + } catch (\Exception $e) { + return 'Error getting projects: '.$e->getMessage(); + } + } + + /** + * Get Linear teams. + * + * @param int $first Number of teams to retrieve + * + * @return array{ + * teams: array, + * }|string + */ + public function getTeams(int $first = 50): array|string + { + try { + $query = ' + query GetTeams($first: Int!) { + teams(first: $first) { + nodes { + id + name + key + description + private + timezone + issueOrdering + createdAt + updatedAt + } + } + } + '; + + $response = $this->httpClient->request('POST', 'https://api.linear.app/graphql', [ + 'headers' => [ + 'Authorization' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'query' => $query, + 'variables' => ['first' => $first], + ], + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error getting teams: '.($data['errors'][0]['message'] ?? 'Unknown error'); + } + + $teams = $data['data']['teams']['nodes'] ?? []; + + return [ + 'teams' => array_map(fn ($team) => [ + 'id' => $team['id'], + 'name' => $team['name'], + 'key' => $team['key'], + 'description' => $team['description'] ?? '', + 'private' => $team['private'], + 'timezone' => $team['timezone'], + 'issueOrdering' => $team['issueOrdering'], + 'createdAt' => $team['createdAt'], + 'updatedAt' => $team['updatedAt'], + ], $teams), + ]; + } catch (\Exception $e) { + return 'Error getting teams: '.$e->getMessage(); + } + } + + /** + * Get Linear cycles. + * + * @param string $teamId Team ID to filter cycles + * @param int $first Number of cycles to retrieve + * + * @return array{ + * cycles: array, + * }|string + */ + public function getCycles( + string $teamId = '', + int $first = 50, + ): array|string { + try { + $query = $this->buildCyclesQuery($teamId, $first); + + $response = $this->httpClient->request('POST', 'https://api.linear.app/graphql', [ + 'headers' => [ + 'Authorization' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'query' => $query, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error getting cycles: '.($data['errors'][0]['message'] ?? 'Unknown error'); + } + + $cycles = $data['data']['cycles']['nodes'] ?? []; + + return [ + 'cycles' => array_map(fn ($cycle) => [ + 'id' => $cycle['id'], + 'number' => $cycle['number'], + 'name' => $cycle['name'], + 'description' => $cycle['description'] ?? '', + 'state' => $cycle['state'], + 'startDate' => $cycle['startDate'], + 'endDate' => $cycle['endDate'], + 'completedAt' => $cycle['completedAt'], + 'team' => [ + 'id' => $cycle['team']['id'], + 'name' => $cycle['team']['name'], + 'key' => $cycle['team']['key'], + ], + 'createdAt' => $cycle['createdAt'], + 'updatedAt' => $cycle['updatedAt'], + ], $cycles), + ]; + } catch (\Exception $e) { + return 'Error getting cycles: '.$e->getMessage(); + } + } + + /** + * Build GraphQL query for getting issues. + */ + private function buildIssuesQuery(string $teamId, string $projectId, string $state, string $priority, string $assigneeId, int $first, string $after): string + { + $filters = []; + if ($teamId) { + $filters[] = "team: { id: \"$teamId\" }"; + } + if ($projectId) { + $filters[] = "project: { id: \"$projectId\" }"; + } + if ($state) { + $filters[] = "state: { name: { eq: \"$state\" } }"; + } + if ($priority) { + $filters[] = 'priority: { eq: '.$this->priorityToInt($priority).' }'; + } + if ($assigneeId) { + $filters[] = "assignee: { id: \"$assigneeId\" }"; + } + + $filterString = !empty($filters) ? '{ '.implode(', ', $filters).' }' : ''; + $afterString = $after ? ", after: \"$after\"" : ''; + + return " + query GetIssues(\$first: Int!) { + issues(filter: $filterString, first: \$first$afterString, orderBy: updatedAt) { + nodes { + id + identifier + title + description + priority + estimate + state { + id + name + type + } + team { + id + name + key + } + project { + id + name + } + assignee { + id + name + email + } + creator { + id + name + email + } + labels { + nodes { + id + name + color + } + } + createdAt + updatedAt + completedAt + url + } + pageInfo { + hasNextPage + endCursor + } + } + } + "; + } + + /** + * Build GraphQL mutation for creating issues. + */ + private function buildCreateIssueMutation(string $teamId, string $title, string $description, string $projectId, string $assigneeId, string $stateId, int $priority, int $estimate, array $labelIds): string + { + $input = "teamId: \"$teamId\", title: \"$title\""; + if ($description) { + $input .= ", description: \"$description\""; + } + if ($projectId) { + $input .= ", projectId: \"$projectId\""; + } + if ($assigneeId) { + $input .= ", assigneeId: \"$assigneeId\""; + } + if ($stateId) { + $input .= ", stateId: \"$stateId\""; + } + if ($priority >= 0) { + $input .= ", priority: $priority"; + } + if ($estimate > 0) { + $input .= ", estimate: $estimate"; + } + if (!empty($labelIds)) { + $input .= ', labelIds: ["'.implode('", "', $labelIds).'"]'; + } + + return " + mutation CreateIssue { + issueCreate(input: { $input }) { + issue { + id + identifier + title + description + priority + estimate + state { + id + name + type + } + team { + id + name + key + } + project { + id + name + } + assignee { + id + name + email + } + creator { + id + name + email + } + labels { + nodes { + id + name + color + } + } + createdAt + updatedAt + url + } + } + } + "; + } + + /** + * Build GraphQL mutation for updating issues. + */ + private function buildUpdateIssueMutation(string $issueId, string $title, string $description, string $stateId, string $assigneeId, int $priority, int $estimate): string + { + $input = "id: \"$issueId\""; + if ($title) { + $input .= ", title: \"$title\""; + } + if ($description) { + $input .= ", description: \"$description\""; + } + if ($stateId) { + $input .= ", stateId: \"$stateId\""; + } + if ($assigneeId) { + $input .= ", assigneeId: \"$assigneeId\""; + } + if ($priority >= 0) { + $input .= ", priority: $priority"; + } + if ($estimate >= 0) { + $input .= ", estimate: $estimate"; + } + + return " + mutation UpdateIssue { + issueUpdate(input: { $input }) { + success + } + } + "; + } + + /** + * Build GraphQL query for getting projects. + */ + private function buildProjectsQuery(string $teamId, int $first): string + { + $filter = $teamId ? "{ team: { id: \"$teamId\" } }" : ''; + + return " + query GetProjects(\$first: Int!) { + projects(filter: $filter, first: \$first, orderBy: updatedAt) { + nodes { + id + name + description + state + progress + startDate + targetDate + team { + id + name + key + } + creator { + id + name + email + } + createdAt + updatedAt + url + } + } + } + "; + } + + /** + * Build GraphQL query for getting cycles. + */ + private function buildCyclesQuery(string $teamId, int $first): string + { + $filter = $teamId ? "{ team: { id: \"$teamId\" } }" : ''; + + return " + query GetCycles(\$first: Int!) { + cycles(filter: $filter, first: \$first, orderBy: updatedAt) { + nodes { + id + number + name + description + state + startDate + endDate + completedAt + team { + id + name + key + } + createdAt + updatedAt + } + } + } + "; + } + + /** + * Convert priority string to integer. + */ + private function priorityToInt(string $priority): int + { + return match (strtolower($priority)) { + 'urgent' => 0, + 'high' => 1, + 'normal' => 2, + 'low' => 3, + default => 2, + }; + } +} diff --git a/src/agent/src/Toolbox/Tool/LinkedIn.php b/src/agent/src/Toolbox/Tool/LinkedIn.php new file mode 100644 index 000000000..314b5baec --- /dev/null +++ b/src/agent/src/Toolbox/Tool/LinkedIn.php @@ -0,0 +1,471 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('linkedin_post_content', 'Tool that posts content to LinkedIn')] +#[AsTool('linkedin_get_profile', 'Tool that gets LinkedIn profile information', method: 'getProfile')] +#[AsTool('linkedin_search_people', 'Tool that searches for people on LinkedIn', method: 'searchPeople')] +#[AsTool('linkedin_search_companies', 'Tool that searches for companies on LinkedIn', method: 'searchCompanies')] +#[AsTool('linkedin_get_company_info', 'Tool that gets LinkedIn company information', method: 'getCompanyInfo')] +#[AsTool('linkedin_get_feed', 'Tool that gets LinkedIn feed posts', method: 'getFeed')] +final readonly class LinkedIn +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private string $apiVersion = 'v2', + private array $options = [], + ) { + } + + /** + * Post content to LinkedIn. + * + * @param string $text Post text content + * @param string $visibility Post visibility (PUBLIC, CONNECTIONS) + * @param array $media Optional media attachments + * + * @return array{ + * id: string, + * activity: string, + * }|string + */ + public function __invoke( + #[With(maximum: 3000)] + string $text, + string $visibility = 'PUBLIC', + array $media = [], + ): array|string { + try { + $postData = [ + 'author' => 'urn:li:person:'.$this->getPersonUrn(), + 'lifecycleState' => 'PUBLISHED', + 'specificContent' => [ + 'com.linkedin.ugc.ShareContent' => [ + 'shareCommentary' => [ + 'text' => $text, + ], + 'shareMediaCategory' => 'NONE', + ], + ], + 'visibility' => [ + 'com.linkedin.ugc.MemberNetworkVisibility' => $visibility, + ], + ]; + + if (!empty($media)) { + $postData['specificContent']['com.linkedin.ugc.ShareContent']['shareMediaCategory'] = 'ARTICLE'; + $postData['specificContent']['com.linkedin.ugc.ShareContent']['media'] = $media; + } + + $response = $this->httpClient->request('POST', "https://api.linkedin.com/{$this->apiVersion}/ugcPosts", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + 'X-Restli-Protocol-Version' => '2.0.0', + ], + 'json' => $postData, + ]); + + $data = $response->toArray(); + + if (isset($data['serviceErrorCode'])) { + return 'Error posting to LinkedIn: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'activity' => $data['activity'] ?? $data['id'], + ]; + } catch (\Exception $e) { + return 'Error posting to LinkedIn: '.$e->getMessage(); + } + } + + /** + * Get LinkedIn profile information. + * + * @param string $personId LinkedIn person ID (optional, defaults to current user) + * + * @return array{ + * id: string, + * firstName: array{localized: array, preferredLocale: array{country: string, language: string}}, + * lastName: array{localized: array, preferredLocale: array{country: string, language: string}}, + * headline: array{localized: array, preferredLocale: array{country: string, language: string}}, + * summary: array{localized: array, preferredLocale: array{country: string, language: string}}, + * location: array{country: string, geographicArea: string, city: string, postalCode: string}, + * industryName: array{localized: array, preferredLocale: array{country: string, language: string}}, + * profilePicture: array{displayImage: string}, + * publicProfileUrl: string, + * numConnections: int, + * }|string + */ + public function getProfile(string $personId = ''): array|string + { + try { + $profileId = $personId ?: $this->getPersonUrn(); + + $fields = 'id,firstName,lastName,headline,summary,location,industryName,profilePicture(displayImage~:playableStreams),publicProfileUrl,numConnections'; + + $response = $this->httpClient->request('GET', "https://api.linkedin.com/{$this->apiVersion}/people/{$profileId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => [ + 'fields' => $fields, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['serviceErrorCode'])) { + return 'Error getting profile: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'firstName' => $data['firstName'], + 'lastName' => $data['lastName'], + 'headline' => $data['headline'], + 'summary' => $data['summary'] ?? ['localized' => [], 'preferredLocale' => ['country' => 'US', 'language' => 'en']], + 'location' => $data['location'] ?? ['country' => '', 'geographicArea' => '', 'city' => '', 'postalCode' => ''], + 'industryName' => $data['industryName'], + 'profilePicture' => $data['profilePicture'] ?? ['displayImage' => ''], + 'publicProfileUrl' => $data['publicProfileUrl'] ?? '', + 'numConnections' => $data['numConnections'] ?? 0, + ]; + } catch (\Exception $e) { + return 'Error getting profile: '.$e->getMessage(); + } + } + + /** + * Search for people on LinkedIn. + * + * @param string $keywords Search keywords + * @param string $industry Industry filter + * @param string $location Location filter + * @param int $count Number of results (1-100) + * + * @return array + */ + public function searchPeople( + #[With(maximum: 500)] + string $keywords, + string $industry = '', + string $location = '', + int $count = 10, + ): array { + try { + $params = [ + 'keywords' => $keywords, + 'count' => min(max($count, 1), 100), + ]; + + if ($industry) { + $params['industry'] = $industry; + } + if ($location) { + $params['location'] = $location; + } + + $response = $this->httpClient->request('GET', "https://api.linkedin.com/{$this->apiVersion}/peopleSearch", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => $params, + ]); + + $data = $response->toArray(); + + if (!isset($data['elements'])) { + return []; + } + + $people = []; + foreach ($data['elements'] as $person) { + $people[] = [ + 'id' => $person['id'], + 'firstName' => $person['firstName']['localized']['en_US'] ?? '', + 'lastName' => $person['lastName']['localized']['en_US'] ?? '', + 'headline' => $person['headline']['localized']['en_US'] ?? '', + 'location' => [ + 'country' => $person['location']['country'] ?? '', + 'geographicArea' => $person['location']['geographicArea'] ?? '', + 'city' => $person['location']['city'] ?? '', + ], + 'industryName' => $person['industryName']['localized']['en_US'] ?? '', + 'publicProfileUrl' => $person['publicProfileUrl'] ?? '', + ]; + } + + return $people; + } catch (\Exception $e) { + return []; + } + } + + /** + * Search for companies on LinkedIn. + * + * @param string $keywords Search keywords + * @param string $industry Industry filter + * @param string $location Location filter + * @param int $count Number of results (1-100) + * + * @return array + */ + public function searchCompanies( + #[With(maximum: 500)] + string $keywords, + string $industry = '', + string $location = '', + int $count = 10, + ): array { + try { + $params = [ + 'keywords' => $keywords, + 'count' => min(max($count, 1), 100), + ]; + + if ($industry) { + $params['industry'] = $industry; + } + if ($location) { + $params['location'] = $location; + } + + $response = $this->httpClient->request('GET', "https://api.linkedin.com/{$this->apiVersion}/companySearch", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => $params, + ]); + + $data = $response->toArray(); + + if (!isset($data['elements'])) { + return []; + } + + $companies = []; + foreach ($data['elements'] as $company) { + $companies[] = [ + 'id' => $company['id'], + 'name' => $company['name']['localized']['en_US'] ?? '', + 'description' => $company['description']['localized']['en_US'] ?? '', + 'industry' => $company['industry'] ?? '', + 'location' => [ + 'country' => $company['location']['country'] ?? '', + 'geographicArea' => $company['location']['geographicArea'] ?? '', + 'city' => $company['location']['city'] ?? '', + ], + 'employeeCountRange' => [ + 'start' => $company['employeeCountRange']['start'] ?? 0, + 'end' => $company['employeeCountRange']['end'] ?? 0, + ], + 'website' => $company['website'] ?? '', + 'logoV2' => [ + 'original' => $company['logoV2']['original'] ?? '', + ], + ]; + } + + return $companies; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get LinkedIn company information. + * + * @param string $companyId LinkedIn company ID + * + * @return array{ + * id: string, + * name: string, + * description: string, + * industry: string, + * location: array{country: string, geographicArea: string, city: string}, + * employeeCountRange: array{start: int, end: int}, + * website: string, + * logoV2: array{original: string}, + * foundedOn: array{year: int, month: int, day: int}, + * specialities: array, + * companySize: string, + * }|string + */ + public function getCompanyInfo(string $companyId): array|string + { + try { + $fields = 'id,name,description,industry,location,employeeCountRange,website,logoV2(original),foundedOn,specialities,companySize'; + + $response = $this->httpClient->request('GET', "https://api.linkedin.com/{$this->apiVersion}/companies/{$companyId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => [ + 'fields' => $fields, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['serviceErrorCode'])) { + return 'Error getting company info: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'name' => $data['name']['localized']['en_US'] ?? '', + 'description' => $data['description']['localized']['en_US'] ?? '', + 'industry' => $data['industry'] ?? '', + 'location' => [ + 'country' => $data['location']['country'] ?? '', + 'geographicArea' => $data['location']['geographicArea'] ?? '', + 'city' => $data['location']['city'] ?? '', + ], + 'employeeCountRange' => [ + 'start' => $data['employeeCountRange']['start'] ?? 0, + 'end' => $data['employeeCountRange']['end'] ?? 0, + ], + 'website' => $data['website'] ?? '', + 'logoV2' => [ + 'original' => $data['logoV2']['original'] ?? '', + ], + 'foundedOn' => [ + 'year' => $data['foundedOn']['year'] ?? 0, + 'month' => $data['foundedOn']['month'] ?? 0, + 'day' => $data['foundedOn']['day'] ?? 0, + ], + 'specialities' => $data['specialities'] ?? [], + 'companySize' => $data['companySize'] ?? '', + ]; + } catch (\Exception $e) { + return 'Error getting company info: '.$e->getMessage(); + } + } + + /** + * Get LinkedIn feed posts. + * + * @param int $count Number of posts (1-100) + * @param string $since Get posts since this date (YYYY-MM-DD) + * + * @return array + */ + public function getFeed(int $count = 20, string $since = ''): array + { + try { + $params = [ + 'count' => min(max($count, 1), 100), + ]; + + if ($since) { + $params['since'] = strtotime($since) * 1000; // Convert to milliseconds + } + + $response = $this->httpClient->request('GET', "https://api.linkedin.com/{$this->apiVersion}/feedUpdates", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => $params, + ]); + + $data = $response->toArray(); + + if (!isset($data['elements'])) { + return []; + } + + $posts = []; + foreach ($data['elements'] as $post) { + $posts[] = [ + 'id' => $post['id'], + 'author' => $post['actor'] ?? '', + 'text' => $post['updateContent']['companyStatusUpdate']['share']['comment'] ?? '', + 'created' => $post['createdTime'] ?? 0, + 'type' => $post['updateType'] ?? 'unknown', + 'visibility' => $post['isCommentable'] ? 'public' : 'private', + 'numLikes' => $post['numLikes'] ?? 0, + 'numComments' => $post['numComments'] ?? 0, + 'numShares' => $post['numShares'] ?? 0, + ]; + } + + return $posts; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get person URN for current user. + */ + private function getPersonUrn(): string + { + try { + $response = $this->httpClient->request('GET', "https://api.linkedin.com/{$this->apiVersion}/people/~", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => [ + 'fields' => 'id', + ], + ]); + + $data = $response->toArray(); + + return $data['id'] ?? ''; + } catch (\Exception $e) { + return ''; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Mailchimp.php b/src/agent/src/Toolbox/Tool/Mailchimp.php new file mode 100644 index 000000000..bc106ef4e --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Mailchimp.php @@ -0,0 +1,742 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('mailchimp_create_campaign', 'Tool that creates Mailchimp email campaigns')] +#[AsTool('mailchimp_add_subscriber', 'Tool that adds subscribers to Mailchimp lists', method: 'addSubscriber')] +#[AsTool('mailchimp_get_list_members', 'Tool that gets Mailchimp list members', method: 'getListMembers')] +#[AsTool('mailchimp_create_list', 'Tool that creates Mailchimp lists', method: 'createList')] +#[AsTool('mailchimp_send_campaign', 'Tool that sends Mailchimp campaigns', method: 'sendCampaign')] +#[AsTool('mailchimp_get_campaign_stats', 'Tool that gets Mailchimp campaign statistics', method: 'getCampaignStats')] +final readonly class Mailchimp +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $dataCenter, + private array $options = [], + ) { + } + + /** + * Create a Mailchimp email campaign. + * + * @param string $type Campaign type (regular, plaintext, absplit, rss, variate) + * @param string $subject Email subject line + * @param string $fromName Sender name + * @param string $fromEmail Sender email address + * @param string $listId List ID to send to + * @param string $htmlContent HTML content of the email + * @param string $plainTextContent Plain text content of the email + * @param string $title Campaign title + * + * @return array{ + * id: string, + * type: string, + * create_time: string, + * archive_url: string, + * long_archive_url: string, + * status: string, + * emails_sent: int, + * send_time: string, + * content_type: string, + * recipient_count: int, + * settings: array{ + * subject_line: string, + * title: string, + * from_name: string, + * reply_to: string, + * use_conversation: bool, + * to_name: string, + * folder_id: string, + * authenticate: bool, + * auto_footer: bool, + * inline_css: bool, + * auto_tweet: bool, + * auto_fb_post: array, + * fb_comments: bool, + * timewarp: bool, + * template_id: int, + * drag_and_drop: bool, + * }, + * recipients: array{ + * list_id: string, + * list_is_active: bool, + * list_name: string, + * segment_text: string, + * recipient_count: int, + * }, + * }|string + */ + public function __invoke( + string $type, + string $subject, + string $fromName, + string $fromEmail, + string $listId, + string $htmlContent, + string $plainTextContent = '', + string $title = '', + ): array|string { + try { + $payload = [ + 'type' => $type, + 'settings' => [ + 'subject_line' => $subject, + 'from_name' => $fromName, + 'reply_to' => $fromEmail, + 'title' => $title ?: $subject, + ], + 'recipients' => [ + 'list_id' => $listId, + ], + 'content_type' => 'template', + ]; + + $response = $this->httpClient->request('POST', "https://{$this->dataCenter}.api.mailchimp.com/3.0/campaigns", [ + 'headers' => [ + 'Authorization' => 'apikey '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && $data['status'] >= 400) { + return 'Error creating campaign: '.($data['detail'] ?? 'Unknown error'); + } + + // Set content after creating the campaign + if ($htmlContent || $plainTextContent) { + $contentPayload = []; + if ($htmlContent) { + $contentPayload['html'] = $htmlContent; + } + if ($plainTextContent) { + $contentPayload['plain_text'] = $plainTextContent; + } + + $this->httpClient->request('PUT', "https://{$this->dataCenter}.api.mailchimp.com/3.0/campaigns/{$data['id']}/content", [ + 'headers' => [ + 'Authorization' => 'apikey '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $contentPayload, + ]); + } + + return [ + 'id' => $data['id'], + 'type' => $data['type'], + 'create_time' => $data['create_time'], + 'archive_url' => $data['archive_url'], + 'long_archive_url' => $data['long_archive_url'], + 'status' => $data['status'], + 'emails_sent' => $data['emails_sent'] ?? 0, + 'send_time' => $data['send_time'] ?? '', + 'content_type' => $data['content_type'], + 'recipient_count' => $data['recipient_count'] ?? 0, + 'settings' => [ + 'subject_line' => $data['settings']['subject_line'], + 'title' => $data['settings']['title'], + 'from_name' => $data['settings']['from_name'], + 'reply_to' => $data['settings']['reply_to'], + 'use_conversation' => $data['settings']['use_conversation'] ?? false, + 'to_name' => $data['settings']['to_name'] ?? '', + 'folder_id' => $data['settings']['folder_id'] ?? '', + 'authenticate' => $data['settings']['authenticate'] ?? false, + 'auto_footer' => $data['settings']['auto_footer'] ?? false, + 'inline_css' => $data['settings']['inline_css'] ?? false, + 'auto_tweet' => $data['settings']['auto_tweet'] ?? false, + 'auto_fb_post' => $data['settings']['auto_fb_post'] ?? [], + 'fb_comments' => $data['settings']['fb_comments'] ?? false, + 'timewarp' => $data['settings']['timewarp'] ?? false, + 'template_id' => $data['settings']['template_id'] ?? 0, + 'drag_and_drop' => $data['settings']['drag_and_drop'] ?? false, + ], + 'recipients' => [ + 'list_id' => $data['recipients']['list_id'], + 'list_is_active' => $data['recipients']['list_is_active'] ?? false, + 'list_name' => $data['recipients']['list_name'] ?? '', + 'segment_text' => $data['recipients']['segment_text'] ?? '', + 'recipient_count' => $data['recipients']['recipient_count'] ?? 0, + ], + ]; + } catch (\Exception $e) { + return 'Error creating campaign: '.$e->getMessage(); + } + } + + /** + * Add a subscriber to a Mailchimp list. + * + * @param string $listId List ID to add subscriber to + * @param string $email Subscriber email address + * @param string $firstName Subscriber first name + * @param string $lastName Subscriber last name + * @param string $status Subscription status (subscribed, unsubscribed, cleaned, pending) + * @param array $mergeFields Optional merge fields + * + * @return array{ + * id: string, + * email_address: string, + * unique_email_id: string, + * contact_id: string, + * full_name: string, + * web_id: int, + * email_type: string, + * status: string, + * unsubscribe_reason: string, + * consents_to_one_to_one_messaging: bool, + * merge_fields: array, + * interests: array, + * stats: array{ + * avg_open_rate: float, + * avg_click_rate: float, + * }, + * ip_signup: string, + * timestamp_signup: string, + * ip_opt: string, + * timestamp_opt: string, + * member_rating: int, + * last_changed: string, + * language: string, + * vip: bool, + * email_client: string, + * location: array{ + * latitude: float, + * longitude: float, + * gmtoff: int, + * dstoff: int, + * country_code: string, + * timezone: string, + * region: string, + * }, + * marketing_permissions: array, + * last_note: array{ + * note_id: int, + * created_at: string, + * created_by: string, + * note: string, + * }, + * source: string, + * tags_count: int, + * tags: array, + * list_id: string, + * _links: array, + * }|string + */ + public function addSubscriber( + string $listId, + string $email, + string $firstName = '', + string $lastName = '', + string $status = 'subscribed', + array $mergeFields = [], + ): array|string { + try { + $payload = [ + 'email_address' => $email, + 'status' => $status, + ]; + + if ($firstName || $lastName) { + $payload['merge_fields'] = array_merge($mergeFields, [ + 'FNAME' => $firstName, + 'LNAME' => $lastName, + ]); + } elseif (!empty($mergeFields)) { + $payload['merge_fields'] = $mergeFields; + } + + $response = $this->httpClient->request('POST', "https://{$this->dataCenter}.api.mailchimp.com/3.0/lists/{$listId}/members", [ + 'headers' => [ + 'Authorization' => 'apikey '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && $data['status'] >= 400) { + return 'Error adding subscriber: '.($data['detail'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'email_address' => $data['email_address'], + 'unique_email_id' => $data['unique_email_id'], + 'contact_id' => $data['contact_id'] ?? '', + 'full_name' => $data['full_name'] ?? '', + 'web_id' => $data['web_id'], + 'email_type' => $data['email_type'] ?? 'html', + 'status' => $data['status'], + 'unsubscribe_reason' => $data['unsubscribe_reason'] ?? '', + 'consents_to_one_to_one_messaging' => $data['consents_to_one_to_one_messaging'] ?? false, + 'merge_fields' => $data['merge_fields'] ?? [], + 'interests' => $data['interests'] ?? [], + 'stats' => [ + 'avg_open_rate' => $data['stats']['avg_open_rate'] ?? 0.0, + 'avg_click_rate' => $data['stats']['avg_click_rate'] ?? 0.0, + ], + 'ip_signup' => $data['ip_signup'] ?? '', + 'timestamp_signup' => $data['timestamp_signup'] ?? '', + 'ip_opt' => $data['ip_opt'] ?? '', + 'timestamp_opt' => $data['timestamp_opt'] ?? '', + 'member_rating' => $data['member_rating'] ?? 0, + 'last_changed' => $data['last_changed'], + 'language' => $data['language'] ?? '', + 'vip' => $data['vip'] ?? false, + 'email_client' => $data['email_client'] ?? '', + 'location' => [ + 'latitude' => $data['location']['latitude'] ?? 0.0, + 'longitude' => $data['location']['longitude'] ?? 0.0, + 'gmtoff' => $data['location']['gmtoff'] ?? 0, + 'dstoff' => $data['location']['dstoff'] ?? 0, + 'country_code' => $data['location']['country_code'] ?? '', + 'timezone' => $data['location']['timezone'] ?? '', + 'region' => $data['location']['region'] ?? '', + ], + 'marketing_permissions' => $data['marketing_permissions'] ?? [], + 'last_note' => [ + 'note_id' => $data['last_note']['note_id'] ?? 0, + 'created_at' => $data['last_note']['created_at'] ?? '', + 'created_by' => $data['last_note']['created_by'] ?? '', + 'note' => $data['last_note']['note'] ?? '', + ], + 'source' => $data['source'] ?? '', + 'tags_count' => $data['tags_count'] ?? 0, + 'tags' => $data['tags'] ?? [], + 'list_id' => $data['list_id'], + '_links' => $data['_links'] ?? [], + ]; + } catch (\Exception $e) { + return 'Error adding subscriber: '.$e->getMessage(); + } + } + + /** + * Get Mailchimp list members. + * + * @param string $listId List ID to get members from + * @param int $count Number of members to retrieve + * @param string $status Filter by status (subscribed, unsubscribed, cleaned, pending, transactional) + * @param int $offset Number of members to skip + * + * @return array, + * interests: array, + * stats: array{ + * avg_open_rate: float, + * avg_click_rate: float, + * }, + * ip_signup: string, + * timestamp_signup: string, + * ip_opt: string, + * timestamp_opt: string, + * member_rating: int, + * last_changed: string, + * language: string, + * vip: bool, + * email_client: string, + * }> + */ + public function getListMembers( + string $listId, + int $count = 10, + string $status = '', + int $offset = 0, + ): array { + try { + $params = [ + 'count' => min(max($count, 1), 1000), + 'offset' => $offset, + ]; + + if ($status) { + $params['status'] = $status; + } + + $response = $this->httpClient->request('GET', "https://{$this->dataCenter}.api.mailchimp.com/3.0/lists/{$listId}/members", [ + 'headers' => [ + 'Authorization' => 'apikey '.$this->apiKey, + ], + 'query' => $params, + ]); + + $data = $response->toArray(); + + if (!isset($data['members'])) { + return []; + } + + $members = []; + foreach ($data['members'] as $member) { + $members[] = [ + 'id' => $member['id'], + 'email_address' => $member['email_address'], + 'unique_email_id' => $member['unique_email_id'], + 'contact_id' => $member['contact_id'] ?? '', + 'full_name' => $member['full_name'] ?? '', + 'web_id' => $member['web_id'], + 'email_type' => $member['email_type'] ?? 'html', + 'status' => $member['status'], + 'merge_fields' => $member['merge_fields'] ?? [], + 'interests' => $member['interests'] ?? [], + 'stats' => [ + 'avg_open_rate' => $member['stats']['avg_open_rate'] ?? 0.0, + 'avg_click_rate' => $member['stats']['avg_click_rate'] ?? 0.0, + ], + 'ip_signup' => $member['ip_signup'] ?? '', + 'timestamp_signup' => $member['timestamp_signup'] ?? '', + 'ip_opt' => $member['ip_opt'] ?? '', + 'timestamp_opt' => $member['timestamp_opt'] ?? '', + 'member_rating' => $member['member_rating'] ?? 0, + 'last_changed' => $member['last_changed'], + 'language' => $member['language'] ?? '', + 'vip' => $member['vip'] ?? false, + 'email_client' => $member['email_client'] ?? '', + ]; + } + + return $members; + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a Mailchimp list. + * + * @param string $name List name + * @param string $company Company name + * @param string $address1 Street address + * @param string $city City + * @param string $state State/province + * @param string $zip ZIP/postal code + * @param string $country Country code + * @param string $fromName From name for campaigns + * @param string $fromEmail From email for campaigns + * @param string $subject Default subject line + * @param string $language Language code + * + * @return array{ + * id: string, + * web_id: int, + * name: string, + * contact: array{ + * company: string, + * address1: string, + * city: string, + * state: string, + * zip: string, + * country: string, + * }, + * permission_reminder: string, + * use_archive_bar: bool, + * campaign_defaults: array{ + * from_name: string, + * from_email: string, + * subject: string, + * language: string, + * }, + * notify_on_subscribe: string, + * notify_on_unsubscribe: string, + * date_created: string, + * list_rating: int, + * email_type_option: bool, + * subscribe_url_short: string, + * subscribe_url_long: string, + * beamer_address: string, + * visibility: string, + * double_optin: bool, + * has_welcome: bool, + * marketing_permissions: bool, + * modules: array, + * stats: array{ + * member_count: int, + * total_contacts: int, + * unsubscribe_count: int, + * cleaned_count: int, + * member_count_since_send: int, + * unsubscribe_count_since_send: int, + * cleaned_count_since_send: int, + * campaign_count: int, + * campaign_last_sent: string, + * merge_field_count: int, + * avg_sub_rate: float, + * avg_unsub_rate: float, + * target_sub_rate: float, + * open_rate: float, + * click_rate: float, + * last_sub_date: string, + * last_unsub_date: string, + * }, + * }|string + */ + public function createList( + string $name, + string $company, + string $address1, + string $city, + string $state, + string $zip, + string $country, + string $fromName, + string $fromEmail, + string $subject, + string $language = 'en', + ): array|string { + try { + $payload = [ + 'name' => $name, + 'contact' => [ + 'company' => $company, + 'address1' => $address1, + 'city' => $city, + 'state' => $state, + 'zip' => $zip, + 'country' => $country, + ], + 'permission_reminder' => 'You are receiving this email because you opted in via our website.', + 'campaign_defaults' => [ + 'from_name' => $fromName, + 'from_email' => $fromEmail, + 'subject' => $subject, + 'language' => $language, + ], + 'email_type_option' => true, + ]; + + $response = $this->httpClient->request('POST', "https://{$this->dataCenter}.api.mailchimp.com/3.0/lists", [ + 'headers' => [ + 'Authorization' => 'apikey '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && $data['status'] >= 400) { + return 'Error creating list: '.($data['detail'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'web_id' => $data['web_id'], + 'name' => $data['name'], + 'contact' => [ + 'company' => $data['contact']['company'], + 'address1' => $data['contact']['address1'], + 'city' => $data['contact']['city'], + 'state' => $data['contact']['state'], + 'zip' => $data['contact']['zip'], + 'country' => $data['contact']['country'], + ], + 'permission_reminder' => $data['permission_reminder'], + 'use_archive_bar' => $data['use_archive_bar'] ?? false, + 'campaign_defaults' => [ + 'from_name' => $data['campaign_defaults']['from_name'], + 'from_email' => $data['campaign_defaults']['from_email'], + 'subject' => $data['campaign_defaults']['subject'], + 'language' => $data['campaign_defaults']['language'], + ], + 'notify_on_subscribe' => $data['notify_on_subscribe'] ?? '', + 'notify_on_unsubscribe' => $data['notify_on_unsubscribe'] ?? '', + 'date_created' => $data['date_created'], + 'list_rating' => $data['list_rating'] ?? 0, + 'email_type_option' => $data['email_type_option'], + 'subscribe_url_short' => $data['subscribe_url_short'], + 'subscribe_url_long' => $data['subscribe_url_long'], + 'beamer_address' => $data['beamer_address'] ?? '', + 'visibility' => $data['visibility'] ?? 'pub', + 'double_optin' => $data['double_optin'] ?? false, + 'has_welcome' => $data['has_welcome'] ?? false, + 'marketing_permissions' => $data['marketing_permissions'] ?? false, + 'modules' => $data['modules'] ?? [], + 'stats' => [ + 'member_count' => $data['stats']['member_count'] ?? 0, + 'total_contacts' => $data['stats']['total_contacts'] ?? 0, + 'unsubscribe_count' => $data['stats']['unsubscribe_count'] ?? 0, + 'cleaned_count' => $data['stats']['cleaned_count'] ?? 0, + 'member_count_since_send' => $data['stats']['member_count_since_send'] ?? 0, + 'unsubscribe_count_since_send' => $data['stats']['unsubscribe_count_since_send'] ?? 0, + 'cleaned_count_since_send' => $data['stats']['cleaned_count_since_send'] ?? 0, + 'campaign_count' => $data['stats']['campaign_count'] ?? 0, + 'campaign_last_sent' => $data['stats']['campaign_last_sent'] ?? '', + 'merge_field_count' => $data['stats']['merge_field_count'] ?? 0, + 'avg_sub_rate' => $data['stats']['avg_sub_rate'] ?? 0.0, + 'avg_unsub_rate' => $data['stats']['avg_unsub_rate'] ?? 0.0, + 'target_sub_rate' => $data['stats']['target_sub_rate'] ?? 0.0, + 'open_rate' => $data['stats']['open_rate'] ?? 0.0, + 'click_rate' => $data['stats']['click_rate'] ?? 0.0, + 'last_sub_date' => $data['stats']['last_sub_date'] ?? '', + 'last_unsub_date' => $data['stats']['last_unsub_date'] ?? '', + ], + ]; + } catch (\Exception $e) { + return 'Error creating list: '.$e->getMessage(); + } + } + + /** + * Send a Mailchimp campaign. + * + * @param string $campaignId Campaign ID to send + */ + public function sendCampaign(string $campaignId): string + { + try { + $response = $this->httpClient->request('POST', "https://{$this->dataCenter}.api.mailchimp.com/3.0/campaigns/{$campaignId}/actions/send", [ + 'headers' => [ + 'Authorization' => 'apikey '.$this->apiKey, + ], + ]); + + if (204 === $response->getStatusCode()) { + return "Campaign {$campaignId} sent successfully"; + } else { + $data = $response->toArray(); + + return 'Error sending campaign: '.($data['detail'] ?? 'Unknown error'); + } + } catch (\Exception $e) { + return 'Error sending campaign: '.$e->getMessage(); + } + } + + /** + * Get Mailchimp campaign statistics. + * + * @param string $campaignId Campaign ID to get stats for + * + * @return array{ + * opens: array{ + * opens_total: int, + * unique_opens: int, + * open_rate: float, + * last_opened: string, + * }, + * clicks: array{ + * clicks_total: int, + * unique_clicks: int, + * unique_subscriber_clicks: int, + * click_rate: float, + * last_clicked: string, + * }, + * facebook_likes: array{ + * recipient_likes: int, + * unique_likes: int, + * facebook_likes: int, + * }, + * industry_stats: array{ + * type: string, + * open_rate: float, + * click_rate: float, + * bounce_rate: float, + * unopen_rate: float, + * unsub_rate: float, + * abuse_rate: float, + * }, + * list_stats: array{ + * sub_rate: float, + * unsub_rate: float, + * open_rate: float, + * click_rate: float, + * }, + * abuse_reports: int, + * unsubscribed: int, + * delivered_count: int, + * }|string + */ + public function getCampaignStats(string $campaignId): array|string + { + try { + $response = $this->httpClient->request('GET', "https://{$this->dataCenter}.api.mailchimp.com/3.0/campaigns/{$campaignId}/reports", [ + 'headers' => [ + 'Authorization' => 'apikey '.$this->apiKey, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['status']) && $data['status'] >= 400) { + return 'Error getting campaign stats: '.($data['detail'] ?? 'Unknown error'); + } + + return [ + 'opens' => [ + 'opens_total' => $data['opens']['opens_total'] ?? 0, + 'unique_opens' => $data['opens']['unique_opens'] ?? 0, + 'open_rate' => $data['opens']['open_rate'] ?? 0.0, + 'last_opened' => $data['opens']['last_opened'] ?? '', + ], + 'clicks' => [ + 'clicks_total' => $data['clicks']['clicks_total'] ?? 0, + 'unique_clicks' => $data['clicks']['unique_clicks'] ?? 0, + 'unique_subscriber_clicks' => $data['clicks']['unique_subscriber_clicks'] ?? 0, + 'click_rate' => $data['clicks']['click_rate'] ?? 0.0, + 'last_clicked' => $data['clicks']['last_clicked'] ?? '', + ], + 'facebook_likes' => [ + 'recipient_likes' => $data['facebook_likes']['recipient_likes'] ?? 0, + 'unique_likes' => $data['facebook_likes']['unique_likes'] ?? 0, + 'facebook_likes' => $data['facebook_likes']['facebook_likes'] ?? 0, + ], + 'industry_stats' => [ + 'type' => $data['industry_stats']['type'] ?? '', + 'open_rate' => $data['industry_stats']['open_rate'] ?? 0.0, + 'click_rate' => $data['industry_stats']['click_rate'] ?? 0.0, + 'bounce_rate' => $data['industry_stats']['bounce_rate'] ?? 0.0, + 'unopen_rate' => $data['industry_stats']['unopen_rate'] ?? 0.0, + 'unsub_rate' => $data['industry_stats']['unsub_rate'] ?? 0.0, + 'abuse_rate' => $data['industry_stats']['abuse_rate'] ?? 0.0, + ], + 'list_stats' => [ + 'sub_rate' => $data['list_stats']['sub_rate'] ?? 0.0, + 'unsub_rate' => $data['list_stats']['unsub_rate'] ?? 0.0, + 'open_rate' => $data['list_stats']['open_rate'] ?? 0.0, + 'click_rate' => $data['list_stats']['click_rate'] ?? 0.0, + ], + 'abuse_reports' => $data['abuse_reports'] ?? 0, + 'unsubscribed' => $data['unsubscribed'] ?? 0, + 'delivered_count' => $data['delivered_count'] ?? 0, + ]; + } catch (\Exception $e) { + return 'Error getting campaign stats: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Memorize.php b/src/agent/src/Toolbox/Tool/Memorize.php new file mode 100644 index 000000000..a92eb6868 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Memorize.php @@ -0,0 +1,701 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('memorize_store', 'Tool that stores information in memory')] +#[AsTool('memorize_retrieve', 'Tool that retrieves information from memory', method: 'retrieve')] +#[AsTool('memorize_search', 'Tool that searches memory for information', method: 'search')] +#[AsTool('memorize_delete', 'Tool that deletes information from memory', method: 'delete')] +#[AsTool('memorize_list', 'Tool that lists all memory entries', method: 'list')] +#[AsTool('memorize_clear', 'Tool that clears all memory', method: 'clear')] +#[AsTool('memorize_update', 'Tool that updates information in memory', method: 'update')] +#[AsTool('memorize_get_stats', 'Tool that gets memory statistics', method: 'getStats')] +final readonly class Memorize +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey = '', + private string $baseUrl = 'https://api.memorize.ai', + private array $options = [], + ) { + } + + /** + * Store information in memory. + * + * @param string $content Content to store + * @param array $metadata Metadata associated with the content + * @param string $category Category for organization + * @param array $tags Tags for easier retrieval + * @param int $priority Priority level (1-10) + * @param string $expiresAt Expiration date (ISO 8601 format) + * + * @return array{ + * success: bool, + * memory: array{ + * id: string, + * content: string, + * metadata: array, + * category: string, + * tags: array, + * priority: int, + * expiresAt: string, + * createdAt: string, + * updatedAt: string, + * accessCount: int, + * lastAccessedAt: string, + * }, + * error: string, + * } + */ + public function __invoke( + string $content, + array $metadata = [], + string $category = 'general', + array $tags = [], + int $priority = 5, + string $expiresAt = '', + ): array { + try { + $requestData = [ + 'content' => $content, + 'metadata' => $metadata, + 'category' => $category, + 'tags' => $tags, + 'priority' => max(1, min($priority, 10)), + ]; + + if ($expiresAt) { + $requestData['expires_at'] = $expiresAt; + } + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['Authorization'] = "Bearer {$this->apiKey}"; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/memories", [ + 'headers' => $headers, + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'memory' => [ + 'id' => $data['id'] ?? '', + 'content' => $data['content'] ?? $content, + 'metadata' => $data['metadata'] ?? $metadata, + 'category' => $data['category'] ?? $category, + 'tags' => $data['tags'] ?? $tags, + 'priority' => $data['priority'] ?? $priority, + 'expiresAt' => $data['expires_at'] ?? $expiresAt, + 'createdAt' => $data['created_at'] ?? date('c'), + 'updatedAt' => $data['updated_at'] ?? date('c'), + 'accessCount' => $data['access_count'] ?? 0, + 'lastAccessedAt' => $data['last_accessed_at'] ?? '', + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'memory' => [ + 'id' => '', + 'content' => $content, + 'metadata' => $metadata, + 'category' => $category, + 'tags' => $tags, + 'priority' => $priority, + 'expiresAt' => $expiresAt, + 'createdAt' => '', + 'updatedAt' => '', + 'accessCount' => 0, + 'lastAccessedAt' => '', + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Retrieve information from memory. + * + * @param string $id Memory ID + * + * @return array{ + * success: bool, + * memory: array{ + * id: string, + * content: string, + * metadata: array, + * category: string, + * tags: array, + * priority: int, + * expiresAt: string, + * createdAt: string, + * updatedAt: string, + * accessCount: int, + * lastAccessedAt: string, + * }, + * error: string, + * } + */ + public function retrieve(string $id): array + { + try { + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['Authorization'] = "Bearer {$this->apiKey}"; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/memories/{$id}", [ + 'headers' => $headers, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'memory' => [ + 'id' => $data['id'] ?? $id, + 'content' => $data['content'] ?? '', + 'metadata' => $data['metadata'] ?? [], + 'category' => $data['category'] ?? '', + 'tags' => $data['tags'] ?? [], + 'priority' => $data['priority'] ?? 0, + 'expiresAt' => $data['expires_at'] ?? '', + 'createdAt' => $data['created_at'] ?? '', + 'updatedAt' => $data['updated_at'] ?? '', + 'accessCount' => $data['access_count'] ?? 0, + 'lastAccessedAt' => $data['last_accessed_at'] ?? '', + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'memory' => [ + 'id' => $id, + 'content' => '', + 'metadata' => [], + 'category' => '', + 'tags' => [], + 'priority' => 0, + 'expiresAt' => '', + 'createdAt' => '', + 'updatedAt' => '', + 'accessCount' => 0, + 'lastAccessedAt' => '', + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search memory for information. + * + * @param string $query Search query + * @param string $category Category filter + * @param array $tags Tags filter + * @param int $limit Number of results + * @param int $offset Offset for pagination + * @param string $sort Sort field (created_at, updated_at, priority, access_count) + * @param string $order Sort order (asc, desc) + * + * @return array{ + * success: bool, + * memories: array, + * category: string, + * tags: array, + * priority: int, + * expiresAt: string, + * createdAt: string, + * updatedAt: string, + * accessCount: int, + * lastAccessedAt: string, + * relevanceScore: float, + * }>, + * total: int, + * limit: int, + * offset: int, + * error: string, + * } + */ + public function search( + string $query, + string $category = '', + array $tags = [], + int $limit = 20, + int $offset = 0, + string $sort = 'created_at', + string $order = 'desc', + ): array { + try { + $params = [ + 'q' => $query, + 'limit' => max(1, min($limit, 100)), + 'offset' => max(0, $offset), + 'sort' => $sort, + 'order' => $order, + ]; + + if ($category) { + $params['category'] = $category; + } + + if (!empty($tags)) { + $params['tags'] = implode(',', $tags); + } + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['Authorization'] = "Bearer {$this->apiKey}"; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/memories/search", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'memories' => array_map(fn ($memory) => [ + 'id' => $memory['id'] ?? '', + 'content' => $memory['content'] ?? '', + 'metadata' => $memory['metadata'] ?? [], + 'category' => $memory['category'] ?? '', + 'tags' => $memory['tags'] ?? [], + 'priority' => $memory['priority'] ?? 0, + 'expiresAt' => $memory['expires_at'] ?? '', + 'createdAt' => $memory['created_at'] ?? '', + 'updatedAt' => $memory['updated_at'] ?? '', + 'accessCount' => $memory['access_count'] ?? 0, + 'lastAccessedAt' => $memory['last_accessed_at'] ?? '', + 'relevanceScore' => $memory['relevance_score'] ?? 0.0, + ], $data['results'] ?? []), + 'total' => $data['total'] ?? 0, + 'limit' => $limit, + 'offset' => $offset, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'memories' => [], + 'total' => 0, + 'limit' => $limit, + 'offset' => $offset, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Delete information from memory. + * + * @param string $id Memory ID + * + * @return array{ + * success: bool, + * message: string, + * error: string, + * } + */ + public function delete(string $id): array + { + try { + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['Authorization'] = "Bearer {$this->apiKey}"; + } + + $response = $this->httpClient->request('DELETE', "{$this->baseUrl}/memories/{$id}", [ + 'headers' => $headers, + ] + $this->options); + + return [ + 'success' => true, + 'message' => 'Memory deleted successfully', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'message' => 'Failed to delete memory', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * List all memory entries. + * + * @param string $category Category filter + * @param int $limit Number of results + * @param int $offset Offset for pagination + * @param string $sort Sort field + * @param string $order Sort order + * + * @return array{ + * success: bool, + * memories: array, + * category: string, + * tags: array, + * priority: int, + * expiresAt: string, + * createdAt: string, + * updatedAt: string, + * accessCount: int, + * lastAccessedAt: string, + * }>, + * total: int, + * limit: int, + * offset: int, + * error: string, + * } + */ + public function list( + string $category = '', + int $limit = 50, + int $offset = 0, + string $sort = 'created_at', + string $order = 'desc', + ): array { + try { + $params = [ + 'limit' => max(1, min($limit, 100)), + 'offset' => max(0, $offset), + 'sort' => $sort, + 'order' => $order, + ]; + + if ($category) { + $params['category'] = $category; + } + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['Authorization'] = "Bearer {$this->apiKey}"; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/memories", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'memories' => array_map(fn ($memory) => [ + 'id' => $memory['id'] ?? '', + 'content' => $memory['content'] ?? '', + 'metadata' => $memory['metadata'] ?? [], + 'category' => $memory['category'] ?? '', + 'tags' => $memory['tags'] ?? [], + 'priority' => $memory['priority'] ?? 0, + 'expiresAt' => $memory['expires_at'] ?? '', + 'createdAt' => $memory['created_at'] ?? '', + 'updatedAt' => $memory['updated_at'] ?? '', + 'accessCount' => $memory['access_count'] ?? 0, + 'lastAccessedAt' => $memory['last_accessed_at'] ?? '', + ], $data['results'] ?? []), + 'total' => $data['total'] ?? 0, + 'limit' => $limit, + 'offset' => $offset, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'memories' => [], + 'total' => 0, + 'limit' => $limit, + 'offset' => $offset, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Clear all memory. + * + * @param string $category Category to clear (empty for all) + * + * @return array{ + * success: bool, + * deletedCount: int, + * message: string, + * error: string, + * } + */ + public function clear(string $category = ''): array + { + try { + $params = []; + + if ($category) { + $params['category'] = $category; + } + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['Authorization'] = "Bearer {$this->apiKey}"; + } + + $response = $this->httpClient->request('DELETE', "{$this->baseUrl}/memories", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'deletedCount' => $data['deleted_count'] ?? 0, + 'message' => $category ? "Cleared {$category} category" : 'Cleared all memory', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'deletedCount' => 0, + 'message' => 'Failed to clear memory', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Update information in memory. + * + * @param string $id Memory ID + * @param string $content New content + * @param array $metadata New metadata + * @param string $category New category + * @param array $tags New tags + * @param int $priority New priority + * @param string $expiresAt New expiration date + * + * @return array{ + * success: bool, + * memory: array{ + * id: string, + * content: string, + * metadata: array, + * category: string, + * tags: array, + * priority: int, + * expiresAt: string, + * createdAt: string, + * updatedAt: string, + * accessCount: int, + * lastAccessedAt: string, + * }, + * error: string, + * } + */ + public function update( + string $id, + string $content = '', + array $metadata = [], + string $category = '', + array $tags = [], + int $priority = 0, + string $expiresAt = '', + ): array { + try { + $requestData = []; + + if ($content) { + $requestData['content'] = $content; + } + + if (!empty($metadata)) { + $requestData['metadata'] = $metadata; + } + + if ($category) { + $requestData['category'] = $category; + } + + if (!empty($tags)) { + $requestData['tags'] = $tags; + } + + if ($priority > 0) { + $requestData['priority'] = max(1, min($priority, 10)); + } + + if ($expiresAt) { + $requestData['expires_at'] = $expiresAt; + } + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['Authorization'] = "Bearer {$this->apiKey}"; + } + + $response = $this->httpClient->request('PUT', "{$this->baseUrl}/memories/{$id}", [ + 'headers' => $headers, + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'memory' => [ + 'id' => $data['id'] ?? $id, + 'content' => $data['content'] ?? $content, + 'metadata' => $data['metadata'] ?? $metadata, + 'category' => $data['category'] ?? $category, + 'tags' => $data['tags'] ?? $tags, + 'priority' => $data['priority'] ?? $priority, + 'expiresAt' => $data['expires_at'] ?? $expiresAt, + 'createdAt' => $data['created_at'] ?? '', + 'updatedAt' => $data['updated_at'] ?? date('c'), + 'accessCount' => $data['access_count'] ?? 0, + 'lastAccessedAt' => $data['last_accessed_at'] ?? '', + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'memory' => [ + 'id' => $id, + 'content' => $content, + 'metadata' => $metadata, + 'category' => $category, + 'tags' => $tags, + 'priority' => $priority, + 'expiresAt' => $expiresAt, + 'createdAt' => '', + 'updatedAt' => '', + 'accessCount' => 0, + 'lastAccessedAt' => '', + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get memory statistics. + * + * @return array{ + * success: bool, + * stats: array{ + * totalMemories: int, + * categories: array, + * totalAccessCount: int, + * averagePriority: float, + * oldestMemory: string, + * newestMemory: string, + * expiredMemories: int, + * memoryByMonth: array, + * }, + * error: string, + * } + */ + public function getStats(): array + { + try { + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['Authorization'] = "Bearer {$this->apiKey}"; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/memories/stats", [ + 'headers' => $headers, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'stats' => [ + 'totalMemories' => $data['total_memories'] ?? 0, + 'categories' => $data['categories'] ?? [], + 'totalAccessCount' => $data['total_access_count'] ?? 0, + 'averagePriority' => $data['average_priority'] ?? 0.0, + 'oldestMemory' => $data['oldest_memory'] ?? '', + 'newestMemory' => $data['newest_memory'] ?? '', + 'expiredMemories' => $data['expired_memories'] ?? 0, + 'memoryByMonth' => $data['memory_by_month'] ?? [], + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'stats' => [ + 'totalMemories' => 0, + 'categories' => [], + 'totalAccessCount' => 0, + 'averagePriority' => 0.0, + 'oldestMemory' => '', + 'newestMemory' => '', + 'expiredMemories' => 0, + 'memoryByMonth' => [], + ], + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/MerriamWebster.php b/src/agent/src/Toolbox/Tool/MerriamWebster.php new file mode 100644 index 000000000..0a39001d4 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/MerriamWebster.php @@ -0,0 +1,336 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('merriam_webster', 'Tool that searches the Merriam-Webster dictionary API')] +#[AsTool('merriam_webster_thesaurus', 'Tool that searches Merriam-Webster thesaurus', method: 'searchThesaurus')] +final readonly class MerriamWebster +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $dictionaryApiKey, + #[\SensitiveParameter] private ?string $thesaurusApiKey = null, + private array $options = [], + ) { + } + + /** + * Search Merriam-Webster dictionary for word definitions. + * + * @param string $word The word to look up + * + * @return array{ + * word: string, + * definitions: array, + * synonyms: array, + * antonyms: array, + * }>, + * pronunciation: array{ + * phonetic: string, + * audio_url: string, + * }, + * etymology: string, + * word_frequency: int, + * }|string + */ + public function __invoke(string $word): array|string + { + try { + $response = $this->httpClient->request('GET', "https://www.dictionaryapi.com/api/v3/references/collegiate/json/{$word}", [ + 'query' => [ + 'key' => $this->dictionaryApiKey, + ], + ]); + + $data = $response->toArray(); + + if (empty($data)) { + return "No definition found for '{$word}'"; + } + + // Handle suggestions if the word is not found + if (isset($data[0]) && \is_string($data[0])) { + return 'Word not found. Did you mean: '.implode(', ', \array_slice($data, 0, 5)).'?'; + } + + $definitions = []; + $pronunciation = ['phonetic' => '', 'audio_url' => '']; + $etymology = ''; + $wordFrequency = 0; + + foreach ($data as $entry) { + if (!isset($entry['shortdef'])) { + continue; + } + + // Extract pronunciation information + if (isset($entry['hwi']['prs'][0]['ipa'])) { + $pronunciation['phonetic'] = $entry['hwi']['prs'][0]['ipa']; + } + if (isset($entry['hwi']['prs'][0]['sound']['audio'])) { + $audioId = $entry['hwi']['prs'][0]['sound']['audio']; + $pronunciation['audio_url'] = "https://media.merriam-webster.com/audio/prons/en/us/mp3/{$audioId[0]}/{$audioId}.mp3"; + } + + // Extract etymology + if (isset($entry['et'][0][1])) { + $etymology = $entry['et'][0][1]; + } + + // Extract word frequency + if (isset($entry['meta']['stems'])) { + $wordFrequency = \count($entry['meta']['stems']); + } + + // Process definitions + foreach ($entry['shortdef'] as $index => $definition) { + $definitions[] = [ + 'definition' => $definition, + 'part_of_speech' => $entry['fl'] ?? '', + 'examples' => $this->extractExamples($entry, $index), + 'synonyms' => $this->extractSynonyms($entry), + 'antonyms' => $this->extractAntonyms($entry), + ]; + } + } + + return [ + 'word' => $word, + 'definitions' => $definitions, + 'pronunciation' => $pronunciation, + 'etymology' => $etymology, + 'word_frequency' => $wordFrequency, + ]; + } catch (\Exception $e) { + return 'Error searching dictionary: '.$e->getMessage(); + } + } + + /** + * Search Merriam-Webster thesaurus for synonyms and antonyms. + * + * @param string $word The word to look up in the thesaurus + * + * @return array{ + * word: string, + * synonyms: array, + * antonyms: array, + * related_words: array, + * }|string + */ + public function searchThesaurus(string $word): array|string + { + if (!$this->thesaurusApiKey) { + return 'Thesaurus API key not provided'; + } + + try { + $response = $this->httpClient->request('GET', "https://www.dictionaryapi.com/api/v3/references/thesaurus/json/{$word}", [ + 'query' => [ + 'key' => $this->thesaurusApiKey, + ], + ]); + + $data = $response->toArray(); + + if (empty($data)) { + return "No thesaurus entry found for '{$word}'"; + } + + // Handle suggestions if the word is not found + if (isset($data[0]) && \is_string($data[0])) { + return 'Word not found. Did you mean: '.implode(', ', \array_slice($data, 0, 5)).'?'; + } + + $synonyms = []; + $antonyms = []; + $relatedWords = []; + + foreach ($data as $entry) { + if (!isset($entry['meta'])) { + continue; + } + + $meta = $entry['meta']; + + // Extract synonyms + if (isset($meta['syns'])) { + foreach ($meta['syns'] as $synonymGroup) { + foreach ($synonymGroup as $synonym) { + $synonyms[] = [ + 'word' => $synonym['wd'] ?? $synonym, + 'part_of_speech' => $entry['fl'] ?? '', + 'definition' => $this->getShortDefinition($entry), + ]; + } + } + } + + // Extract antonyms + if (isset($meta['ants'])) { + foreach ($meta['ants'] as $antonymGroup) { + foreach ($antonymGroup as $antonym) { + $antonyms[] = [ + 'word' => $antonym['wd'] ?? $antonym, + 'part_of_speech' => $entry['fl'] ?? '', + 'definition' => $this->getShortDefinition($entry), + ]; + } + } + } + + // Extract related words + if (isset($meta['rels'])) { + foreach ($meta['rels'] as $relatedWord) { + if (isset($relatedWord['wd'])) { + $relatedWords[] = $relatedWord['wd']; + } + } + } + } + + return [ + 'word' => $word, + 'synonyms' => array_unique($synonyms, \SORT_REGULAR), + 'antonyms' => array_unique($antonyms, \SORT_REGULAR), + 'related_words' => array_unique($relatedWords), + ]; + } catch (\Exception $e) { + return 'Error searching thesaurus: '.$e->getMessage(); + } + } + + /** + * Get word of the day. + * + * @return array{ + * word: string, + * definition: string, + * pronunciation: string, + * example: string, + * date: string, + * }|string + */ + public function getWordOfTheDay(): array|string + { + try { + // Note: This would require a different API endpoint or web scraping + // For now, we'll return a placeholder + return 'Word of the day feature requires additional API access'; + } catch (\Exception $e) { + return 'Error getting word of the day: '.$e->getMessage(); + } + } + + /** + * Extract examples from dictionary entry. + * + * @param array $entry + * + * @return array + */ + private function extractExamples(array $entry, int $definitionIndex): array + { + $examples = []; + + if (isset($entry['def'][0]['sseq'][$definitionIndex][0][1]['dt'][1][1])) { + $exampleData = $entry['def'][0]['sseq'][$definitionIndex][0][1]['dt'][1][1]; + if (\is_array($exampleData)) { + foreach ($exampleData as $example) { + if (isset($example['t'])) { + $examples[] = strip_tags($example['t']); + } + } + } + } + + return $examples; + } + + /** + * Extract synonyms from dictionary entry. + * + * @param array $entry + * + * @return array + */ + private function extractSynonyms(array $entry): array + { + $synonyms = []; + + if (isset($entry['meta']['syns'])) { + foreach ($entry['meta']['syns'] as $synonymGroup) { + foreach ($synonymGroup as $synonym) { + $synonyms[] = \is_array($synonym) ? ($synonym['wd'] ?? '') : $synonym; + } + } + } + + return array_filter($synonyms); + } + + /** + * Extract antonyms from dictionary entry. + * + * @param array $entry + * + * @return array + */ + private function extractAntonyms(array $entry): array + { + $antonyms = []; + + if (isset($entry['meta']['ants'])) { + foreach ($entry['meta']['ants'] as $antonymGroup) { + foreach ($antonymGroup as $antonym) { + $antonyms[] = \is_array($antonym) ? ($antonym['wd'] ?? '') : $antonym; + } + } + } + + return array_filter($antonyms); + } + + /** + * Get short definition from entry. + * + * @param array $entry + */ + private function getShortDefinition(array $entry): string + { + if (isset($entry['shortdef'][0])) { + return $entry['shortdef'][0]; + } + + return ''; + } +} diff --git a/src/agent/src/Toolbox/Tool/MetaphorSearch.php b/src/agent/src/Toolbox/Tool/MetaphorSearch.php new file mode 100644 index 000000000..2f12b8a27 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/MetaphorSearch.php @@ -0,0 +1,306 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('metaphor_search', 'Tool that searches using Metaphor neural search')] +#[AsTool('metaphor_find_similar', 'Tool that finds similar content using Metaphor', method: 'findSimilar')] +#[AsTool('metaphor_get_contents', 'Tool that gets content from URLs using Metaphor', method: 'getContents')] +#[AsTool('metaphor_summarize', 'Tool that summarizes content using Metaphor', method: 'summarize')] +final readonly class MetaphorSearch +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $baseUrl = 'https://api.metaphor.systems', + private array $options = [], + ) { + } + + /** + * Search using Metaphor neural search. + * + * @param string $query Search query + * @param int $numResults Number of results + * @param string $useAutoprompt Use autoprompt for better results + * @param string $includeDomains Domains to include (comma-separated) + * @param string $excludeDomains Domains to exclude (comma-separated) + * @param string $startCrawlDate Start crawl date (YYYY-MM-DD) + * @param string $endCrawlDate End crawl date (YYYY-MM-DD) + * @param string $startPublishedDate Start published date (YYYY-MM-DD) + * @param string $endPublishedDate End published date (YYYY-MM-DD) + * + * @return array{ + * results: array, + * autopromptString: string|null, + * } + */ + public function __invoke( + string $query, + int $numResults = 10, + string $useAutoprompt = 'false', + string $includeDomains = '', + string $excludeDomains = '', + string $startCrawlDate = '', + string $endCrawlDate = '', + string $startPublishedDate = '', + string $endPublishedDate = '', + ): array { + try { + $body = [ + 'query' => $query, + 'numResults' => min(max($numResults, 1), 20), + 'useAutoprompt' => 'true' === $useAutoprompt, + ]; + + if ($includeDomains) { + $body['includeDomains'] = explode(',', $includeDomains); + } + if ($excludeDomains) { + $body['excludeDomains'] = explode(',', $excludeDomains); + } + if ($startCrawlDate) { + $body['startCrawlDate'] = $startCrawlDate; + } + if ($endCrawlDate) { + $body['endCrawlDate'] = $endCrawlDate; + } + if ($startPublishedDate) { + $body['startPublishedDate'] = $startPublishedDate; + } + if ($endPublishedDate) { + $body['endPublishedDate'] = $endPublishedDate; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/search", [ + 'headers' => [ + 'x-api-key' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $body, + ]); + + $data = $response->toArray(); + + return [ + 'results' => array_map(fn ($result) => [ + 'id' => $result['id'], + 'title' => $result['title'], + 'url' => $result['url'], + 'publishedDate' => $result['publishedDate'] ?? null, + 'author' => $result['author'] ?? null, + 'score' => $result['score'], + 'extract' => $result['extract'] ?? '', + ], $data['results'] ?? []), + 'autopromptString' => $data['autopromptString'] ?? null, + ]; + } catch (\Exception $e) { + return [ + 'results' => [], + 'autopromptString' => null, + ]; + } + } + + /** + * Find similar content using Metaphor. + * + * @param string $url URL to find similar content for + * @param int $numResults Number of results + * @param string $includeDomains Domains to include (comma-separated) + * @param string $excludeDomains Domains to exclude (comma-separated) + * + * @return array{ + * results: array, + * } + */ + public function findSimilar( + string $url, + int $numResults = 10, + string $includeDomains = '', + string $excludeDomains = '', + ): array { + try { + $body = [ + 'url' => $url, + 'numResults' => min(max($numResults, 1), 20), + ]; + + if ($includeDomains) { + $body['includeDomains'] = explode(',', $includeDomains); + } + if ($excludeDomains) { + $body['excludeDomains'] = explode(',', $excludeDomains); + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/findSimilar", [ + 'headers' => [ + 'x-api-key' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $body, + ]); + + $data = $response->toArray(); + + return [ + 'results' => array_map(fn ($result) => [ + 'id' => $result['id'], + 'title' => $result['title'], + 'url' => $result['url'], + 'publishedDate' => $result['publishedDate'] ?? null, + 'author' => $result['author'] ?? null, + 'score' => $result['score'], + 'extract' => $result['extract'] ?? '', + ], $data['results'] ?? []), + ]; + } catch (\Exception $e) { + return [ + 'results' => [], + ]; + } + } + + /** + * Get content from URLs using Metaphor. + * + * @param array $ids Document IDs to get content for + * @param string $format Content format (extract, markdown, html) + * + * @return array{ + * contents: array, + * } + */ + public function getContents( + array $ids, + string $format = 'extract', + ): array { + try { + $body = [ + 'ids' => $ids, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/getContents", [ + 'headers' => [ + 'x-api-key' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $body, + ]); + + $data = $response->toArray(); + + return [ + 'contents' => array_map(fn ($content) => [ + 'id' => $content['id'], + 'url' => $content['url'], + 'title' => $content['title'], + 'extract' => $content['extract'] ?? '', + 'markdown' => $content['markdown'] ?? null, + 'html' => $content['html'] ?? null, + 'publishedDate' => $content['publishedDate'] ?? null, + 'author' => $content['author'] ?? null, + ], $data['contents'] ?? []), + ]; + } catch (\Exception $e) { + return [ + 'contents' => [], + ]; + } + } + + /** + * Summarize content using Metaphor. + * + * @param array $ids Document IDs to summarize + * @param string $summaryLength Summary length (short, medium, long) + * + * @return array{ + * summary: string, + * sources: array, + * } + */ + public function summarize( + array $ids, + string $summaryLength = 'medium', + ): array { + try { + $body = [ + 'ids' => $ids, + 'summaryLength' => $summaryLength, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/summarize", [ + 'headers' => [ + 'x-api-key' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $body, + ]); + + $data = $response->toArray(); + + return [ + 'summary' => $data['summary'] ?? '', + 'sources' => array_map(fn ($source) => [ + 'id' => $source['id'], + 'title' => $source['title'], + 'url' => $source['url'], + 'score' => $source['score'] ?? 0.0, + ], $data['sources'] ?? []), + ]; + } catch (\Exception $e) { + return [ + 'summary' => 'Error generating summary: '.$e->getMessage(), + 'sources' => [], + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/MojeekSearch.php b/src/agent/src/Toolbox/Tool/MojeekSearch.php new file mode 100644 index 000000000..55d4bb6db --- /dev/null +++ b/src/agent/src/Toolbox/Tool/MojeekSearch.php @@ -0,0 +1,438 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('mojeek_search', 'Tool that searches using Mojeek search engine')] +#[AsTool('mojeek_search_news', 'Tool that searches news using Mojeek', method: 'searchNews')] +#[AsTool('mojeek_search_images', 'Tool that searches images using Mojeek', method: 'searchImages')] +#[AsTool('mojeek_autocomplete', 'Tool that provides autocomplete suggestions using Mojeek', method: 'autocomplete')] +final readonly class MojeekSearch +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey = '', + private string $baseUrl = 'https://api.mojeek.com', + private array $options = [], + ) { + } + + /** + * Search using Mojeek search engine. + * + * @param string $query Search query + * @param int $offset Result offset + * @param int $count Number of results + * @param string $format Response format (json, xml) + * @param string $lang Language code + * @param string $country Country code + * @param string $safesearch Safe search level (off, moderate, strict) + * @param string $type Search type (web, news, images) + * + * @return array{ + * success: bool, + * results: array, + * query: array{ + * text: string, + * offset: int, + * count: int, + * total: int, + * }, + * response: array{ + * time: float, + * status: string, + * version: string, + * }, + * error: string, + * } + */ + public function __invoke( + string $query, + int $offset = 0, + int $count = 10, + string $format = 'json', + string $lang = 'en', + string $country = 'us', + string $safesearch = 'moderate', + string $type = 'web', + ): array { + try { + $params = [ + 'q' => $query, + 'offset' => max(0, $offset), + 'count' => max(1, min($count, 100)), + 'format' => $format, + 'lang' => $lang, + 'country' => $country, + 'safesearch' => $safesearch, + 'type' => $type, + ]; + + if ($this->apiKey) { + $params['api_key'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'results' => array_map(fn ($result) => [ + 'title' => $result['title'] ?? '', + 'desc' => $result['desc'] ?? '', + 'url' => $result['url'] ?? '', + 'date' => $result['date'] ?? '', + 'engine' => $result['engine'] ?? 'mojeek', + 'rank' => $result['rank'] ?? 0, + 'score' => $result['score'] ?? 0.0, + ], $data['results'] ?? []), + 'query' => [ + 'text' => $data['query']['text'] ?? $query, + 'offset' => $data['query']['offset'] ?? $offset, + 'count' => $data['query']['count'] ?? $count, + 'total' => $data['query']['total'] ?? 0, + ], + 'response' => [ + 'time' => $data['response']['time'] ?? 0.0, + 'status' => $data['response']['status'] ?? 'ok', + 'version' => $data['response']['version'] ?? '1.0', + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'results' => [], + 'query' => ['text' => $query, 'offset' => $offset, 'count' => $count, 'total' => 0], + 'response' => ['time' => 0.0, 'status' => 'error', 'version' => '1.0'], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search news using Mojeek. + * + * @param string $query News search query + * @param int $offset Result offset + * @param int $count Number of results + * @param string $lang Language code + * @param string $country Country code + * @param string $timeframe Time frame (day, week, month, year) + * + * @return array{ + * success: bool, + * news: array, + * query: array{ + * text: string, + * offset: int, + * count: int, + * total: int, + * }, + * response: array{ + * time: float, + * status: string, + * version: string, + * }, + * error: string, + * } + */ + public function searchNews( + string $query, + int $offset = 0, + int $count = 10, + string $lang = 'en', + string $country = 'us', + string $timeframe = '', + ): array { + try { + $params = [ + 'q' => $query, + 'offset' => max(0, $offset), + 'count' => max(1, min($count, 100)), + 'format' => 'json', + 'lang' => $lang, + 'country' => $country, + 'type' => 'news', + ]; + + if ($timeframe) { + $params['time'] = $timeframe; + } + + if ($this->apiKey) { + $params['api_key'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'news' => array_map(fn ($article) => [ + 'title' => $article['title'] ?? '', + 'desc' => $article['desc'] ?? '', + 'url' => $article['url'] ?? '', + 'date' => $article['date'] ?? '', + 'source' => $article['source'] ?? '', + 'author' => $article['author'] ?? '', + 'image' => $article['image'] ?? '', + 'rank' => $article['rank'] ?? 0, + ], $data['results'] ?? []), + 'query' => [ + 'text' => $data['query']['text'] ?? $query, + 'offset' => $data['query']['offset'] ?? $offset, + 'count' => $data['query']['count'] ?? $count, + 'total' => $data['query']['total'] ?? 0, + ], + 'response' => [ + 'time' => $data['response']['time'] ?? 0.0, + 'status' => $data['response']['status'] ?? 'ok', + 'version' => $data['response']['version'] ?? '1.0', + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'news' => [], + 'query' => ['text' => $query, 'offset' => $offset, 'count' => $count, 'total' => 0], + 'response' => ['time' => 0.0, 'status' => 'error', 'version' => '1.0'], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search images using Mojeek. + * + * @param string $query Image search query + * @param int $offset Result offset + * @param int $count Number of results + * @param string $lang Language code + * @param string $country Country code + * @param string $size Image size filter (small, medium, large, xlarge) + * @param string $color Color filter (color, grayscale, transparent) + * @param string $type Image type filter (photo, clipart, lineart, animated) + * + * @return array{ + * success: bool, + * images: array, + * query: array{ + * text: string, + * offset: int, + * count: int, + * total: int, + * }, + * response: array{ + * time: float, + * status: string, + * version: string, + * }, + * error: string, + * } + */ + public function searchImages( + string $query, + int $offset = 0, + int $count = 20, + string $lang = 'en', + string $country = 'us', + string $size = '', + string $color = '', + string $type = '', + ): array { + try { + $params = [ + 'q' => $query, + 'offset' => max(0, $offset), + 'count' => max(1, min($count, 100)), + 'format' => 'json', + 'lang' => $lang, + 'country' => $country, + 'type' => 'images', + ]; + + if ($size) { + $params['size'] = $size; + } + + if ($color) { + $params['color'] = $color; + } + + if ($type) { + $params['imgtype'] = $type; + } + + if ($this->apiKey) { + $params['api_key'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'images' => array_map(fn ($image) => [ + 'title' => $image['title'] ?? '', + 'desc' => $image['desc'] ?? '', + 'url' => $image['url'] ?? '', + 'imgUrl' => $image['imgUrl'] ?? '', + 'thumbnailUrl' => $image['thumbnailUrl'] ?? '', + 'source' => $image['source'] ?? '', + 'size' => $image['size'] ?? '', + 'format' => $image['format'] ?? '', + 'rank' => $image['rank'] ?? 0, + ], $data['results'] ?? []), + 'query' => [ + 'text' => $data['query']['text'] ?? $query, + 'offset' => $data['query']['offset'] ?? $offset, + 'count' => $data['query']['count'] ?? $count, + 'total' => $data['query']['total'] ?? 0, + ], + 'response' => [ + 'time' => $data['response']['time'] ?? 0.0, + 'status' => $data['response']['status'] ?? 'ok', + 'version' => $data['response']['version'] ?? '1.0', + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'images' => [], + 'query' => ['text' => $query, 'offset' => $offset, 'count' => $count, 'total' => 0], + 'response' => ['time' => 0.0, 'status' => 'error', 'version' => '1.0'], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get autocomplete suggestions using Mojeek. + * + * @param string $query Partial query for autocomplete + * @param int $count Number of suggestions + * @param string $lang Language code + * @param string $country Country code + * + * @return array{ + * success: bool, + * suggestions: array, + * query: array{ + * text: string, + * count: int, + * }, + * response: array{ + * time: float, + * status: string, + * version: string, + * }, + * error: string, + * } + */ + public function autocomplete( + string $query, + int $count = 10, + string $lang = 'en', + string $country = 'us', + ): array { + try { + $params = [ + 'q' => $query, + 'count' => max(1, min($count, 20)), + 'format' => 'json', + 'lang' => $lang, + 'country' => $country, + 'type' => 'suggest', + ]; + + if ($this->apiKey) { + $params['api_key'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'suggestions' => $data['suggestions'] ?? [], + 'query' => [ + 'text' => $data['query']['text'] ?? $query, + 'count' => $data['query']['count'] ?? $count, + ], + 'response' => [ + 'time' => $data['response']['time'] ?? 0.0, + 'status' => $data['response']['status'] ?? 'ok', + 'version' => $data['response']['version'] ?? '1.0', + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'suggestions' => [], + 'query' => ['text' => $query, 'count' => $count], + 'response' => ['time' => 0.0, 'status' => 'error', 'version' => '1.0'], + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Multion.php b/src/agent/src/Toolbox/Tool/Multion.php new file mode 100644 index 000000000..a759c32d8 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Multion.php @@ -0,0 +1,820 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('multion_navigate', 'Tool that navigates web pages using Multion')] +#[AsTool('multion_search', 'Tool that searches on web pages', method: 'search')] +#[AsTool('multion_extract', 'Tool that extracts data from web pages', method: 'extract')] +#[AsTool('multion_interact', 'Tool that interacts with web elements', method: 'interact')] +#[AsTool('multion_screenshot', 'Tool that takes screenshots', method: 'screenshot')] +#[AsTool('multion_form_fill', 'Tool that fills forms', method: 'formFill')] +#[AsTool('multion_click', 'Tool that clicks elements', method: 'click')] +#[AsTool('multion_type', 'Tool that types text', method: 'type')] +final readonly class Multion +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.multion.com/v1', + private array $options = [], + ) { + } + + /** + * Navigate web pages using Multion. + * + * @param string $url URL to navigate to + * @param array $options Navigation options + * @param array $context Navigation context + * + * @return array{ + * success: bool, + * navigation: array{ + * url: string, + * final_url: string, + * title: string, + * status_code: int, + * load_time: float, + * page_content: array{ + * html: string, + * text: string, + * links: array, + * images: array, + * forms: array, + * }>, + * }, + * metadata: array{ + * viewport: array{ + * width: int, + * height: int, + * }, + * user_agent: string, + * cookies: array, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $url, + array $options = [], + array $context = [], + ): array { + try { + $requestData = [ + 'url' => $url, + 'options' => $options, + 'context' => $context, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/navigate", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $navigation = $responseData['navigation'] ?? []; + + return [ + 'success' => true, + 'navigation' => [ + 'url' => $url, + 'final_url' => $navigation['final_url'] ?? $url, + 'title' => $navigation['title'] ?? '', + 'status_code' => $navigation['status_code'] ?? 200, + 'load_time' => $navigation['load_time'] ?? 0.0, + 'page_content' => [ + 'html' => $navigation['page_content']['html'] ?? '', + 'text' => $navigation['page_content']['text'] ?? '', + 'links' => array_map(fn ($link) => [ + 'text' => $link['text'] ?? '', + 'url' => $link['url'] ?? '', + ], $navigation['page_content']['links'] ?? []), + 'images' => array_map(fn ($image) => [ + 'src' => $image['src'] ?? '', + 'alt' => $image['alt'] ?? '', + ], $navigation['page_content']['images'] ?? []), + 'forms' => array_map(fn ($form) => [ + 'action' => $form['action'] ?? '', + 'method' => $form['method'] ?? 'GET', + 'fields' => array_map(fn ($field) => [ + 'name' => $field['name'] ?? '', + 'type' => $field['type'] ?? 'text', + 'required' => $field['required'] ?? false, + ], $form['fields'] ?? []), + ], $navigation['page_content']['forms'] ?? []), + ], + 'metadata' => [ + 'viewport' => [ + 'width' => $navigation['metadata']['viewport']['width'] ?? 1920, + 'height' => $navigation['metadata']['viewport']['height'] ?? 1080, + ], + 'user_agent' => $navigation['metadata']['user_agent'] ?? '', + 'cookies' => array_map(fn ($cookie) => [ + 'name' => $cookie['name'] ?? '', + 'value' => $cookie['value'] ?? '', + 'domain' => $cookie['domain'] ?? '', + ], $navigation['metadata']['cookies'] ?? []), + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'navigation' => [ + 'url' => $url, + 'final_url' => $url, + 'title' => '', + 'status_code' => 0, + 'load_time' => 0.0, + 'page_content' => [ + 'html' => '', + 'text' => '', + 'links' => [], + 'images' => [], + 'forms' => [], + ], + 'metadata' => [ + 'viewport' => [ + 'width' => 1920, + 'height' => 1080, + ], + 'user_agent' => '', + 'cookies' => [], + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search on web pages. + * + * @param string $query Search query + * @param string $searchEngine Search engine to use + * @param array $options Search options + * + * @return array{ + * success: bool, + * search_results: array{ + * query: string, + * search_engine: string, + * results: array, + * total_results: int, + * search_time: float, + * related_searches: array, + * filters: array>, + * }, + * processingTime: float, + * error: string, + * } + */ + public function search( + string $query, + string $searchEngine = 'google', + array $options = [], + ): array { + try { + $requestData = [ + 'query' => $query, + 'search_engine' => $searchEngine, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/search", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $searchResults = $responseData['search_results'] ?? []; + + return [ + 'success' => true, + 'search_results' => [ + 'query' => $query, + 'search_engine' => $searchEngine, + 'results' => array_map(fn ($result, $index) => [ + 'title' => $result['title'] ?? '', + 'url' => $result['url'] ?? '', + 'description' => $result['description'] ?? '', + 'position' => $result['position'] ?? $index + 1, + 'domain' => parse_url($result['url'] ?? '', \PHP_URL_HOST) ?: '', + ], $searchResults['results'] ?? []), + 'total_results' => $searchResults['total_results'] ?? 0, + 'search_time' => $searchResults['search_time'] ?? 0.0, + 'related_searches' => $searchResults['related_searches'] ?? [], + 'filters' => $searchResults['filters'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'search_results' => [ + 'query' => $query, + 'search_engine' => $searchEngine, + 'results' => [], + 'total_results' => 0, + 'search_time' => 0.0, + 'related_searches' => [], + 'filters' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Extract data from web pages. + * + * @param string $url URL to extract data from + * @param array $extractionRules Extraction rules + * @param array $options Extraction options + * + * @return array{ + * success: bool, + * extraction: array{ + * url: string, + * extracted_data: array, + * extraction_rules: array, + * elements_found: array, + * metadata: array{ + * extraction_time: float, + * page_size: int, + * elements_count: int, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function extract( + string $url, + array $extractionRules = [], + array $options = [], + ): array { + try { + $requestData = [ + 'url' => $url, + 'extraction_rules' => $extractionRules, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/extract", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $extraction = $responseData['extraction'] ?? []; + + return [ + 'success' => true, + 'extraction' => [ + 'url' => $url, + 'extracted_data' => $extraction['extracted_data'] ?? [], + 'extraction_rules' => $extractionRules, + 'elements_found' => array_map(fn ($element) => [ + 'selector' => $element['selector'] ?? '', + 'value' => $element['value'] ?? '', + 'confidence' => $element['confidence'] ?? 0.0, + ], $extraction['elements_found'] ?? []), + 'metadata' => [ + 'extraction_time' => $extraction['metadata']['extraction_time'] ?? 0.0, + 'page_size' => $extraction['metadata']['page_size'] ?? 0, + 'elements_count' => $extraction['metadata']['elements_count'] ?? 0, + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'extraction' => [ + 'url' => $url, + 'extracted_data' => [], + 'extraction_rules' => $extractionRules, + 'elements_found' => [], + 'metadata' => [ + 'extraction_time' => 0.0, + 'page_size' => 0, + 'elements_count' => 0, + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Interact with web elements. + * + * @param string $action Action to perform + * @param array $element Element selector or identifier + * @param array $data Action data + * + * @return array{ + * success: bool, + * interaction: array{ + * action: string, + * element: array, + * data: array, + * result: array{ + * success: bool, + * message: string, + * page_changed: bool, + * new_url: string, + * }, + * screenshot_url: string, + * execution_time: float, + * }, + * processingTime: float, + * error: string, + * } + */ + public function interact( + string $action, + array $element, + array $data = [], + ): array { + try { + $requestData = [ + 'action' => $action, + 'element' => $element, + 'data' => $data, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/interact", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $interaction = $responseData['interaction'] ?? []; + + return [ + 'success' => true, + 'interaction' => [ + 'action' => $action, + 'element' => $element, + 'data' => $data, + 'result' => [ + 'success' => $interaction['result']['success'] ?? false, + 'message' => $interaction['result']['message'] ?? '', + 'page_changed' => $interaction['result']['page_changed'] ?? false, + 'new_url' => $interaction['result']['new_url'] ?? '', + ], + 'screenshot_url' => $interaction['screenshot_url'] ?? '', + 'execution_time' => $interaction['execution_time'] ?? 0.0, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'interaction' => [ + 'action' => $action, + 'element' => $element, + 'data' => $data, + 'result' => [ + 'success' => false, + 'message' => '', + 'page_changed' => false, + 'new_url' => '', + ], + 'screenshot_url' => '', + 'execution_time' => 0.0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Take screenshots. + * + * @param string $url URL to screenshot (optional) + * @param array $options Screenshot options + * + * @return array{ + * success: bool, + * screenshot: array{ + * url: string, + * screenshot_url: string, + * dimensions: array{ + * width: int, + * height: int, + * }, + * format: string, + * size_bytes: int, + * metadata: array{ + * timestamp: string, + * viewport: array{ + * width: int, + * height: int, + * }, + * full_page: bool, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function screenshot( + string $url = '', + array $options = [], + ): array { + try { + $requestData = [ + 'url' => $url, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/screenshot", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $screenshot = $responseData['screenshot'] ?? []; + + return [ + 'success' => true, + 'screenshot' => [ + 'url' => $url, + 'screenshot_url' => $screenshot['screenshot_url'] ?? '', + 'dimensions' => [ + 'width' => $screenshot['dimensions']['width'] ?? 1920, + 'height' => $screenshot['dimensions']['height'] ?? 1080, + ], + 'format' => $screenshot['format'] ?? 'png', + 'size_bytes' => $screenshot['size_bytes'] ?? 0, + 'metadata' => [ + 'timestamp' => $screenshot['metadata']['timestamp'] ?? date('c'), + 'viewport' => [ + 'width' => $screenshot['metadata']['viewport']['width'] ?? 1920, + 'height' => $screenshot['metadata']['viewport']['height'] ?? 1080, + ], + 'full_page' => $screenshot['metadata']['full_page'] ?? false, + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'screenshot' => [ + 'url' => $url, + 'screenshot_url' => '', + 'dimensions' => [ + 'width' => 1920, + 'height' => 1080, + ], + 'format' => 'png', + 'size_bytes' => 0, + 'metadata' => [ + 'timestamp' => date('c'), + 'viewport' => [ + 'width' => 1920, + 'height' => 1080, + ], + 'full_page' => false, + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Fill forms. + * + * @param array $formData Form data to fill + * @param string $formSelector Form selector + * @param array $options Form filling options + * + * @return array{ + * success: bool, + * form_filling: array{ + * form_selector: string, + * form_data: array, + * fields_filled: array, + * result: array{ + * success: bool, + * message: string, + * form_submitted: bool, + * redirect_url: string, + * }, + * screenshot_url: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function formFill( + array $formData, + string $formSelector = '', + array $options = [], + ): array { + try { + $requestData = [ + 'form_data' => $formData, + 'form_selector' => $formSelector, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/form-fill", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $formFilling = $responseData['form_filling'] ?? []; + + return [ + 'success' => true, + 'form_filling' => [ + 'form_selector' => $formSelector, + 'form_data' => $formData, + 'fields_filled' => array_map(fn ($field) => [ + 'field_name' => $field['field_name'] ?? '', + 'value' => $field['value'] ?? '', + 'success' => $field['success'] ?? false, + ], $formFilling['fields_filled'] ?? []), + 'result' => [ + 'success' => $formFilling['result']['success'] ?? false, + 'message' => $formFilling['result']['message'] ?? '', + 'form_submitted' => $formFilling['result']['form_submitted'] ?? false, + 'redirect_url' => $formFilling['result']['redirect_url'] ?? '', + ], + 'screenshot_url' => $formFilling['screenshot_url'] ?? '', + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'form_filling' => [ + 'form_selector' => $formSelector, + 'form_data' => $formData, + 'fields_filled' => [], + 'result' => [ + 'success' => false, + 'message' => '', + 'form_submitted' => false, + 'redirect_url' => '', + ], + 'screenshot_url' => '', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Click elements. + * + * @param array $element Element to click + * @param array $options Click options + * + * @return array{ + * success: bool, + * click: array{ + * element: array, + * result: array{ + * success: bool, + * message: string, + * page_changed: bool, + * new_url: string, + * }, + * screenshot_url: string, + * execution_time: float, + * }, + * processingTime: float, + * error: string, + * } + */ + public function click( + array $element, + array $options = [], + ): array { + try { + $requestData = [ + 'element' => $element, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/click", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $click = $responseData['click'] ?? []; + + return [ + 'success' => true, + 'click' => [ + 'element' => $element, + 'result' => [ + 'success' => $click['result']['success'] ?? false, + 'message' => $click['result']['message'] ?? '', + 'page_changed' => $click['result']['page_changed'] ?? false, + 'new_url' => $click['result']['new_url'] ?? '', + ], + 'screenshot_url' => $click['screenshot_url'] ?? '', + 'execution_time' => $click['execution_time'] ?? 0.0, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'click' => [ + 'element' => $element, + 'result' => [ + 'success' => false, + 'message' => '', + 'page_changed' => false, + 'new_url' => '', + ], + 'screenshot_url' => '', + 'execution_time' => 0.0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Type text. + * + * @param string $text Text to type + * @param array $element Target element + * @param array $options Typing options + * + * @return array{ + * success: bool, + * typing: array{ + * text: string, + * element: array, + * result: array{ + * success: bool, + * message: string, + * characters_typed: int, + * }, + * screenshot_url: string, + * execution_time: float, + * }, + * processingTime: float, + * error: string, + * } + */ + public function type( + string $text, + array $element, + array $options = [], + ): array { + try { + $requestData = [ + 'text' => $text, + 'element' => $element, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/type", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $typing = $responseData['typing'] ?? []; + + return [ + 'success' => true, + 'typing' => [ + 'text' => $text, + 'element' => $element, + 'result' => [ + 'success' => $typing['result']['success'] ?? false, + 'message' => $typing['result']['message'] ?? '', + 'characters_typed' => $typing['result']['characters_typed'] ?? 0, + ], + 'screenshot_url' => $typing['screenshot_url'] ?? '', + 'execution_time' => $typing['execution_time'] ?? 0.0, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'typing' => [ + 'text' => $text, + 'element' => $element, + 'result' => [ + 'success' => false, + 'message' => '', + 'characters_typed' => 0, + ], + 'screenshot_url' => '', + 'execution_time' => 0.0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Nasa.php b/src/agent/src/Toolbox/Tool/Nasa.php new file mode 100644 index 000000000..4058a0deb --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Nasa.php @@ -0,0 +1,347 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('nasa_images', 'Tool that searches NASA Image and Video Library')] +#[AsTool('nasa_apod', 'Tool that gets NASA Astronomy Picture of the Day', method: 'getApod')] +#[AsTool('nasa_asteroids', 'Tool that gets information about near-Earth asteroids', method: 'getAsteroids')] +#[AsTool('nasa_earth_imagery', 'Tool that gets satellite imagery of Earth', method: 'getEarthImagery')] +final readonly class Nasa +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private ?string $apiKey = null, + private array $options = [], + ) { + } + + /** + * Search NASA Image and Video Library. + * + * @param string $query Search query for NASA images and videos + * @param int $maxResults Maximum number of results to return + * @param string $mediaType Media type filter: image, video, audio + * @param string $yearStart Start year for date range filter + * @param string $yearEnd End year for date range filter + * + * @return array, + * thumbnail_url: string, + * large_url: string, + * href: string, + * }> + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + int $maxResults = 20, + string $mediaType = 'image', + string $yearStart = '', + string $yearEnd = '', + ): array { + try { + $params = [ + 'q' => $query, + 'media_type' => $mediaType, + 'page_size' => $maxResults, + ]; + + if ($this->apiKey) { + $params['api_key'] = $this->apiKey; + } + + if ($yearStart) { + $params['year_start'] = $yearStart; + } + if ($yearEnd) { + $params['year_end'] = $yearEnd; + } + + $response = $this->httpClient->request('GET', 'https://images-api.nasa.gov/search', [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['collection']['items'])) { + return []; + } + + $results = []; + foreach ($data['collection']['items'] as $item) { + $metadata = $item['data'][0] ?? []; + $links = $item['links'] ?? []; + + // Find thumbnail and large image URLs + $thumbnailUrl = ''; + $largeUrl = ''; + $href = ''; + + foreach ($links as $link) { + if ('image' === $link['render']) { + if (str_contains($link['href'], 'thumb')) { + $thumbnailUrl = $link['href']; + } else { + $largeUrl = $link['href']; + } + $href = $link['href']; + } + } + + $results[] = [ + 'nasa_id' => $metadata['nasa_id'] ?? '', + 'title' => $metadata['title'] ?? '', + 'description' => $metadata['description'] ?? '', + 'center' => $metadata['center'] ?? '', + 'date_created' => $metadata['date_created'] ?? '', + 'media_type' => $metadata['media_type'] ?? '', + 'photographer' => $metadata['photographer'] ?? '', + 'keywords' => $metadata['keywords'] ?? [], + 'thumbnail_url' => $thumbnailUrl, + 'large_url' => $largeUrl, + 'href' => $href, + ]; + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'nasa_id' => 'error', + 'title' => 'Search Error', + 'description' => 'Unable to search NASA images: '.$e->getMessage(), + 'center' => '', + 'date_created' => '', + 'media_type' => '', + 'photographer' => '', + 'keywords' => [], + 'thumbnail_url' => '', + 'large_url' => '', + 'href' => '', + ], + ]; + } + } + + /** + * Get NASA Astronomy Picture of the Day. + * + * @param string $date Specific date (YYYY-MM-DD) or leave empty for today + * + * @return array{ + * date: string, + * explanation: string, + * hdurl: string, + * media_type: string, + * service_version: string, + * title: string, + * url: string, + * copyright: string, + * }|string + */ + public function getApod(string $date = ''): array|string + { + try { + $params = []; + if ($this->apiKey) { + $params['api_key'] = $this->apiKey; + } + if ($date) { + $params['date'] = $date; + } + + $response = $this->httpClient->request('GET', 'https://api.nasa.gov/planetary/apod', [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'date' => $data['date'], + 'explanation' => $data['explanation'], + 'hdurl' => $data['hdurl'] ?? '', + 'media_type' => $data['media_type'], + 'service_version' => $data['service_version'] ?? '', + 'title' => $data['title'], + 'url' => $data['url'], + 'copyright' => $data['copyright'] ?? '', + ]; + } catch (\Exception $e) { + return 'Error getting APOD: '.$e->getMessage(); + } + } + + /** + * Get information about near-Earth asteroids. + * + * @param string $startDate Start date for asteroid data (YYYY-MM-DD) + * @param string $endDate End date for asteroid data (YYYY-MM-DD) + * + * @return array, + * }>|string + */ + public function getAsteroids(string $startDate, string $endDate = ''): array|string + { + try { + if (!$this->apiKey) { + return 'NASA API key required for asteroid data'; + } + + $params = [ + 'start_date' => $startDate, + 'api_key' => $this->apiKey, + ]; + + if ($endDate) { + $params['end_date'] = $endDate; + } + + $response = $this->httpClient->request('GET', 'https://api.nasa.gov/neo/rest/v1/feed', [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['near_earth_objects'])) { + return []; + } + + $asteroids = []; + foreach ($data['near_earth_objects'] as $date => $dayAsteroids) { + foreach ($dayAsteroids as $asteroid) { + $asteroids[] = [ + 'name' => $asteroid['name'], + 'nasa_jpl_url' => $asteroid['nasa_jpl_url'], + 'absolute_magnitude_h' => $asteroid['absolute_magnitude_h'], + 'estimated_diameter' => $asteroid['estimated_diameter'], + 'is_potentially_hazardous_asteroid' => $asteroid['is_potentially_hazardous_asteroid'], + 'close_approach_data' => $asteroid['close_approach_data'], + ]; + } + } + + return $asteroids; + } catch (\Exception $e) { + return 'Error getting asteroid data: '.$e->getMessage(); + } + } + + /** + * Get satellite imagery of Earth. + * + * @param float $lat Latitude + * @param float $lon Longitude + * @param string $date Date for imagery (YYYY-MM-DD) + * + * @return array{ + * date: string, + * id: string, + * resource: array{ + * dataset: string, + * planet: string, + * }, + * service_version: string, + * url: string, + * }|string + */ + public function getEarthImagery(float $lat, float $lon, string $date = ''): array|string + { + try { + if (!$this->apiKey) { + return 'NASA API key required for Earth imagery'; + } + + $params = [ + 'lat' => $lat, + 'lon' => $lon, + 'api_key' => $this->apiKey, + ]; + + if ($date) { + $params['date'] = $date; + } + + $response = $this->httpClient->request('GET', 'https://api.nasa.gov/planetary/earth/imagery', [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'date' => $data['date'], + 'id' => $data['id'], + 'resource' => $data['resource'], + 'service_version' => $data['service_version'], + 'url' => $data['url'], + ]; + } catch (\Exception $e) { + return 'Error getting Earth imagery: '.$e->getMessage(); + } + } + + /** + * Get Mars weather data. + * + * @return array{ + * sol_keys: array, + * validity_checks: array, + * latest_weather: array, + * }|string + */ + public function getMarsWeather(): array|string + { + try { + $response = $this->httpClient->request('GET', 'https://api.nasa.gov/insight_weather/', [ + 'query' => array_merge($this->options, [ + 'api_key' => $this->apiKey, + 'feedtype' => 'json', + 'ver' => '1.0', + ]), + ]); + + return $response->toArray(); + } catch (\Exception $e) { + return 'Error getting Mars weather: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Netlify.php b/src/agent/src/Toolbox/Tool/Netlify.php new file mode 100644 index 000000000..9c0c6ebdc --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Netlify.php @@ -0,0 +1,734 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('netlify_get_sites', 'Tool that gets Netlify sites')] +#[AsTool('netlify_create_site', 'Tool that creates Netlify sites', method: 'createSite')] +#[AsTool('netlify_get_deploys', 'Tool that gets Netlify deploys', method: 'getDeploys')] +#[AsTool('netlify_create_deploy', 'Tool that creates Netlify deploys', method: 'createDeploy')] +#[AsTool('netlify_get_dns_zones', 'Tool that gets Netlify DNS zones', method: 'getDnsZones')] +#[AsTool('netlify_get_forms', 'Tool that gets Netlify forms', method: 'getForms')] +final readonly class Netlify +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private string $apiVersion = 'v1', + private array $options = [], + ) { + } + + /** + * Get Netlify sites. + * + * @param string $name Site name filter + * @param string $filter Filter (all, owner, guest) + * @param int $perPage Number of sites per page + * @param int $page Page number + * + * @return array, + * screenshot_url: string|null, + * created_at: string, + * updated_at: string, + * user_id: string, + * session_id: string|null, + * ssl: bool, + * force_ssl: bool, + * managed_dns: bool, + * deploy_url: string, + * state: string, + * admin_url: string, + * published_deploy: array{ + * id: string, + * site_id: string, + * user_id: string, + * build_id: string|null, + * state: string, + * name: string, + * url: string, + * ssl_url: string, + * admin_url: string, + * deploy_url: string, + * deploy_ssl_url: string, + * screenshot_url: string|null, + * review_id: int|null, + * draft: bool, + * required: array, + * required_functions: array, + * error_message: string|null, + * branch: string, + * commit_ref: string|null, + * commit_url: string|null, + * skipped: bool, + * locked: bool, + * created_at: string, + * updated_at: string, + * published_at: string, + * title: string|null, + * context: string, + * deploy_time: int|null, + * links: array, + * }, + * build_settings: array{ + * id: int, + * provider: string, + * deploy_key_id: string, + * repo_path: string, + * repo_branch: string, + * dir: string, + * functions_dir: string|null, + * cmd: string|null, + * allowed_branches: array, + * public_repo: bool, + * private_logs: bool, + * repo_url: string, + * env: array, + * installation_id: int|null, + * stop_builds: bool, + * created_at: string, + * updated_at: string, + * }|null, + * capabilities: array{ + * large_media_enabled: bool, + * forms: array{ + * enabled: bool, + * max_file_size: int, + * max_files: int, + * max_fields: int, + * max_submissions: int, + * spam_filter: array{enabled: bool}, + * }, + * functions: array{ + * enabled: bool, + * max_invocations: int, + * max_execution_time: int, + * }, + * split_testing: array{enabled: bool}, + * identity: array{enabled: bool}, + * large_media: array{enabled: bool}, + * background_functions: array{enabled: bool}, + * edge_functions: array{enabled: bool}, + * }, + * }> + */ + public function __invoke( + string $name = '', + string $filter = 'all', + int $perPage = 50, + int $page = 1, + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + 'filter' => $filter, + ]; + + if ($name) { + $params['name'] = $name; + } + + $response = $this->httpClient->request('GET', "https://api.netlify.com/api/{$this->apiVersion}/sites", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($site) => [ + 'id' => $site['id'], + 'name' => $site['name'], + 'url' => $site['url'], + 'custom_domain' => $site['custom_domain'], + 'domain_aliases' => $site['domain_aliases'] ?? [], + 'screenshot_url' => $site['screenshot_url'], + 'created_at' => $site['created_at'], + 'updated_at' => $site['updated_at'], + 'user_id' => $site['user_id'], + 'session_id' => $site['session_id'], + 'ssl' => $site['ssl'] ?? false, + 'force_ssl' => $site['force_ssl'] ?? false, + 'managed_dns' => $site['managed_dns'] ?? false, + 'deploy_url' => $site['deploy_url'], + 'state' => $site['state'] ?? 'ready', + 'admin_url' => $site['admin_url'], + 'published_deploy' => $site['published_deploy'] ? [ + 'id' => $site['published_deploy']['id'], + 'site_id' => $site['published_deploy']['site_id'], + 'user_id' => $site['published_deploy']['user_id'], + 'build_id' => $site['published_deploy']['build_id'], + 'state' => $site['published_deploy']['state'], + 'name' => $site['published_deploy']['name'], + 'url' => $site['published_deploy']['url'], + 'ssl_url' => $site['published_deploy']['ssl_url'], + 'admin_url' => $site['published_deploy']['admin_url'], + 'deploy_url' => $site['published_deploy']['deploy_url'], + 'deploy_ssl_url' => $site['published_deploy']['deploy_ssl_url'], + 'screenshot_url' => $site['published_deploy']['screenshot_url'], + 'review_id' => $site['published_deploy']['review_id'], + 'draft' => $site['published_deploy']['draft'] ?? false, + 'required' => $site['published_deploy']['required'] ?? [], + 'required_functions' => $site['published_deploy']['required_functions'] ?? [], + 'error_message' => $site['published_deploy']['error_message'], + 'branch' => $site['published_deploy']['branch'], + 'commit_ref' => $site['published_deploy']['commit_ref'], + 'commit_url' => $site['published_deploy']['commit_url'], + 'skipped' => $site['published_deploy']['skipped'] ?? false, + 'locked' => $site['published_deploy']['locked'] ?? false, + 'created_at' => $site['published_deploy']['created_at'], + 'updated_at' => $site['published_deploy']['updated_at'], + 'published_at' => $site['published_deploy']['published_at'], + 'title' => $site['published_deploy']['title'], + 'context' => $site['published_deploy']['context'], + 'deploy_time' => $site['published_deploy']['deploy_time'], + 'links' => $site['published_deploy']['links'] ?? [], + ] : null, + 'build_settings' => $site['build_settings'] ? [ + 'id' => $site['build_settings']['id'], + 'provider' => $site['build_settings']['provider'], + 'deploy_key_id' => $site['build_settings']['deploy_key_id'], + 'repo_path' => $site['build_settings']['repo_path'], + 'repo_branch' => $site['build_settings']['repo_branch'], + 'dir' => $site['build_settings']['dir'], + 'functions_dir' => $site['build_settings']['functions_dir'], + 'cmd' => $site['build_settings']['cmd'], + 'allowed_branches' => $site['build_settings']['allowed_branches'] ?? [], + 'public_repo' => $site['build_settings']['public_repo'] ?? false, + 'private_logs' => $site['build_settings']['private_logs'] ?? false, + 'repo_url' => $site['build_settings']['repo_url'], + 'env' => $site['build_settings']['env'] ?? [], + 'installation_id' => $site['build_settings']['installation_id'], + 'stop_builds' => $site['build_settings']['stop_builds'] ?? false, + 'created_at' => $site['build_settings']['created_at'], + 'updated_at' => $site['build_settings']['updated_at'], + ] : null, + 'capabilities' => [ + 'large_media_enabled' => $site['capabilities']['large_media_enabled'] ?? false, + 'forms' => [ + 'enabled' => $site['capabilities']['forms']['enabled'] ?? false, + 'max_file_size' => $site['capabilities']['forms']['max_file_size'] ?? 10485760, + 'max_files' => $site['capabilities']['forms']['max_files'] ?? 10, + 'max_fields' => $site['capabilities']['forms']['max_fields'] ?? 100, + 'max_submissions' => $site['capabilities']['forms']['max_submissions'] ?? 1000, + 'spam_filter' => [ + 'enabled' => $site['capabilities']['forms']['spam_filter']['enabled'] ?? false, + ], + ], + 'functions' => [ + 'enabled' => $site['capabilities']['functions']['enabled'] ?? false, + 'max_invocations' => $site['capabilities']['functions']['max_invocations'] ?? 125000, + 'max_execution_time' => $site['capabilities']['functions']['max_execution_time'] ?? 10, + ], + 'split_testing' => ['enabled' => $site['capabilities']['split_testing']['enabled'] ?? false], + 'identity' => ['enabled' => $site['capabilities']['identity']['enabled'] ?? false], + 'large_media' => ['enabled' => $site['capabilities']['large_media']['enabled'] ?? false], + 'background_functions' => ['enabled' => $site['capabilities']['background_functions']['enabled'] ?? false], + 'edge_functions' => ['enabled' => $site['capabilities']['edge_functions']['enabled'] ?? false], + ], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a Netlify site. + * + * @param string $name Site name + * @param string $repoUrl Git repository URL + * @param string $branch Git branch + * @param string $dir Build directory + * @param string $cmd Build command + * @param string $functionsDir Functions directory + * + * @return array{ + * id: string, + * name: string, + * url: string, + * custom_domain: string|null, + * domain_aliases: array, + * created_at: string, + * updated_at: string, + * user_id: string, + * ssl: bool, + * force_ssl: bool, + * managed_dns: bool, + * deploy_url: string, + * state: string, + * admin_url: string, + * build_settings: array{ + * id: int, + * provider: string, + * repo_path: string, + * repo_branch: string, + * dir: string, + * functions_dir: string|null, + * cmd: string|null, + * allowed_branches: array, + * public_repo: bool, + * private_logs: bool, + * repo_url: string, + * env: array, + * stop_builds: bool, + * created_at: string, + * updated_at: string, + * }, + * }|string + */ + public function createSite( + string $name, + string $repoUrl = '', + string $branch = 'main', + string $dir = '', + string $cmd = '', + string $functionsDir = '', + ): array|string { + try { + $payload = [ + 'name' => $name, + ]; + + if ($repoUrl) { + $payload['build_settings'] = [ + 'repo_url' => $repoUrl, + 'repo_branch' => $branch, + 'dir' => $dir, + 'cmd' => $cmd, + 'functions_dir' => $functionsDir, + ]; + } + + $response = $this->httpClient->request('POST', "https://api.netlify.com/api/{$this->apiVersion}/sites", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating site: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'url' => $data['url'], + 'custom_domain' => $data['custom_domain'], + 'domain_aliases' => $data['domain_aliases'] ?? [], + 'created_at' => $data['created_at'], + 'updated_at' => $data['updated_at'], + 'user_id' => $data['user_id'], + 'ssl' => $data['ssl'] ?? false, + 'force_ssl' => $data['force_ssl'] ?? false, + 'managed_dns' => $data['managed_dns'] ?? false, + 'deploy_url' => $data['deploy_url'], + 'state' => $data['state'] ?? 'ready', + 'admin_url' => $data['admin_url'], + 'build_settings' => [ + 'id' => $data['build_settings']['id'], + 'provider' => $data['build_settings']['provider'], + 'repo_path' => $data['build_settings']['repo_path'], + 'repo_branch' => $data['build_settings']['repo_branch'], + 'dir' => $data['build_settings']['dir'], + 'functions_dir' => $data['build_settings']['functions_dir'], + 'cmd' => $data['build_settings']['cmd'], + 'allowed_branches' => $data['build_settings']['allowed_branches'] ?? [], + 'public_repo' => $data['build_settings']['public_repo'] ?? false, + 'private_logs' => $data['build_settings']['private_logs'] ?? false, + 'repo_url' => $data['build_settings']['repo_url'], + 'env' => $data['build_settings']['env'] ?? [], + 'stop_builds' => $data['build_settings']['stop_builds'] ?? false, + 'created_at' => $data['build_settings']['created_at'], + 'updated_at' => $data['build_settings']['updated_at'], + ], + ]; + } catch (\Exception $e) { + return 'Error creating site: '.$e->getMessage(); + } + } + + /** + * Get Netlify deploys. + * + * @param string $siteId Site ID + * @param int $perPage Number of deploys per page + * @param int $page Page number + * + * @return array, + * required_functions: array, + * error_message: string|null, + * branch: string, + * commit_ref: string|null, + * commit_url: string|null, + * skipped: bool, + * locked: bool, + * created_at: string, + * updated_at: string, + * published_at: string, + * title: string|null, + * context: string, + * deploy_time: int|null, + * links: array, + * }> + */ + public function getDeploys( + string $siteId, + int $perPage = 50, + int $page = 1, + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + ]; + + $response = $this->httpClient->request('GET', "https://api.netlify.com/api/{$this->apiVersion}/sites/{$siteId}/deploys", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($deploy) => [ + 'id' => $deploy['id'], + 'site_id' => $deploy['site_id'], + 'user_id' => $deploy['user_id'], + 'build_id' => $deploy['build_id'], + 'state' => $deploy['state'], + 'name' => $deploy['name'], + 'url' => $deploy['url'], + 'ssl_url' => $deploy['ssl_url'], + 'admin_url' => $deploy['admin_url'], + 'deploy_url' => $deploy['deploy_url'], + 'deploy_ssl_url' => $deploy['deploy_ssl_url'], + 'screenshot_url' => $deploy['screenshot_url'], + 'review_id' => $deploy['review_id'], + 'draft' => $deploy['draft'] ?? false, + 'required' => $deploy['required'] ?? [], + 'required_functions' => $deploy['required_functions'] ?? [], + 'error_message' => $deploy['error_message'], + 'branch' => $deploy['branch'], + 'commit_ref' => $deploy['commit_ref'], + 'commit_url' => $deploy['commit_url'], + 'skipped' => $deploy['skipped'] ?? false, + 'locked' => $deploy['locked'] ?? false, + 'created_at' => $deploy['created_at'], + 'updated_at' => $deploy['updated_at'], + 'published_at' => $deploy['published_at'], + 'title' => $deploy['title'], + 'context' => $deploy['context'], + 'deploy_time' => $deploy['deploy_time'], + 'links' => $deploy['links'] ?? [], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a Netlify deploy. + * + * @param string $siteId Site ID + * @param string $title Deploy title + * @param string $branch Git branch + * @param string $commitRef Commit reference + * @param bool $draft Whether deploy is draft + * + * @return array{ + * id: string, + * site_id: string, + * user_id: string, + * state: string, + * name: string, + * url: string, + * ssl_url: string, + * admin_url: string, + * deploy_url: string, + * deploy_ssl_url: string, + * draft: bool, + * branch: string, + * commit_ref: string|null, + * created_at: string, + * updated_at: string, + * published_at: string, + * title: string|null, + * context: string, + * links: array, + * }|string + */ + public function createDeploy( + string $siteId, + string $title = '', + string $branch = 'main', + string $commitRef = '', + bool $draft = false, + ): array|string { + try { + $payload = [ + 'branch' => $branch, + 'draft' => $draft, + ]; + + if ($title) { + $payload['title'] = $title; + } + if ($commitRef) { + $payload['commit_ref'] = $commitRef; + } + + $response = $this->httpClient->request('POST', "https://api.netlify.com/api/{$this->apiVersion}/sites/{$siteId}/deploys", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating deploy: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'site_id' => $data['site_id'], + 'user_id' => $data['user_id'], + 'state' => $data['state'], + 'name' => $data['name'], + 'url' => $data['url'], + 'ssl_url' => $data['ssl_url'], + 'admin_url' => $data['admin_url'], + 'deploy_url' => $data['deploy_url'], + 'deploy_ssl_url' => $data['deploy_ssl_url'], + 'draft' => $data['draft'] ?? false, + 'branch' => $data['branch'], + 'commit_ref' => $data['commit_ref'], + 'created_at' => $data['created_at'], + 'updated_at' => $data['updated_at'], + 'published_at' => $data['published_at'], + 'title' => $data['title'], + 'context' => $data['context'], + 'links' => $data['links'] ?? [], + ]; + } catch (\Exception $e) { + return 'Error creating deploy: '.$e->getMessage(); + } + } + + /** + * Get Netlify DNS zones. + * + * @return array, + * supported_record_types: array, + * records: array, + * }> + */ + public function getDnsZones(): array + { + try { + $response = $this->httpClient->request('GET', "https://api.netlify.com/api/{$this->apiVersion}/dns_zones", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($zone) => [ + 'id' => $zone['id'], + 'name' => $zone['name'], + 'user_id' => $zone['user_id'], + 'created_at' => $zone['created_at'], + 'updated_at' => $zone['updated_at'], + 'dns_servers' => $zone['dns_servers'] ?? [], + 'supported_record_types' => $zone['supported_record_types'] ?? [], + 'records' => array_map(fn ($record) => [ + 'id' => $record['id'], + 'hostname' => $record['hostname'], + 'type' => $record['type'], + 'value' => $record['value'], + 'ttl' => $record['ttl'], + 'priority' => $record['priority'], + 'dns_zone_id' => $record['dns_zone_id'], + 'created_at' => $record['created_at'], + 'updated_at' => $record['updated_at'], + ], $zone['records'] ?? []), + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Netlify forms. + * + * @param string $siteId Site ID + * @param int $perPage Number of forms per page + * @param int $page Page number + * + * @return array, + * }>, + * }> + */ + public function getForms( + string $siteId, + int $perPage = 50, + int $page = 1, + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + ]; + + $response = $this->httpClient->request('GET', "https://api.netlify.com/api/{$this->apiVersion}/sites/{$siteId}/forms", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($form) => [ + 'id' => $form['id'], + 'site_id' => $form['site_id'], + 'name' => $form['name'], + 'submission_count' => $form['submission_count'] ?? 0, + 'honeypot' => $form['honeypot'] ?? false, + 'recaptcha' => $form['recaptcha'] ?? false, + 'recaptcha_secret_key' => $form['recaptcha_secret_key'], + 'notification_email' => $form['notification_email'], + 'notification_email_enabled' => $form['notification_email_enabled'] ?? false, + 'notification_slack' => $form['notification_slack'], + 'notification_slack_enabled' => $form['notification_slack_enabled'] ?? false, + 'notification_webhook' => $form['notification_webhook'], + 'notification_webhook_enabled' => $form['notification_webhook_enabled'] ?? false, + 'notification_discord' => $form['notification_discord'], + 'notification_discord_enabled' => $form['notification_discord_enabled'] ?? false, + 'notification_github' => $form['notification_github'], + 'notification_github_enabled' => $form['notification_github_enabled'] ?? false, + 'notification_microsoft_teams' => $form['notification_microsoft_teams'], + 'notification_microsoft_teams_enabled' => $form['notification_microsoft_teams_enabled'] ?? false, + 'created_at' => $form['created_at'], + 'updated_at' => $form['updated_at'], + 'fields' => array_map(fn ($field) => [ + 'name' => $field['name'], + 'type' => $field['type'], + 'required' => $field['required'] ?? false, + 'placeholder' => $field['placeholder'] ?? '', + 'label' => $field['label'] ?? '', + 'options' => $field['options'] ?? [], + ], $form['fields'] ?? []), + ], $data); + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/NewRelic.php b/src/agent/src/Toolbox/Tool/NewRelic.php new file mode 100644 index 000000000..2f706cd88 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/NewRelic.php @@ -0,0 +1,549 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('newrelic_get_applications', 'Tool that gets New Relic applications')] +#[AsTool('newrelic_get_metrics', 'Tool that gets New Relic metrics', method: 'getMetrics')] +#[AsTool('newrelic_get_events', 'Tool that gets New Relic events', method: 'getEvents')] +#[AsTool('newrelic_get_alerts', 'Tool that gets New Relic alerts', method: 'getAlerts')] +#[AsTool('newrelic_get_deployments', 'Tool that gets New Relic deployments', method: 'getDeployments')] +#[AsTool('newrelic_get_errors', 'Tool that gets New Relic errors', method: 'getErrors')] +final readonly class NewRelic +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $apiVersion = 'v2', + private array $options = [], + ) { + } + + /** + * Get New Relic applications. + * + * @param string $name Application name filter + * @param int $perPage Number of applications per page + * @param int $page Page number + * + * @return array, + * alert_policy: int|null, + * server: array, + * }, + * settings: array{ + * app_apdex_threshold: float, + * end_user_apdex_threshold: float, + * enable_real_user_monitoring: bool, + * use_server_side_config: bool, + * }, + * }> + */ + public function __invoke( + string $name = '', + int $perPage = 50, + int $page = 1, + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + ]; + + if ($name) { + $params['filter[name]'] = $name; + } + + $response = $this->httpClient->request('GET', "https://api.newrelic.com/{$this->apiVersion}/applications.json", [ + 'headers' => [ + 'X-Api-Key' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($app) => [ + 'id' => $app['id'], + 'name' => $app['name'], + 'language' => $app['language'], + 'health_status' => $app['health_status'], + 'reporting' => $app['reporting'], + 'last_reported_at' => $app['last_reported_at'], + 'application_summary' => [ + 'response_time' => $app['application_summary']['response_time'], + 'throughput' => $app['application_summary']['throughput'], + 'error_rate' => $app['application_summary']['error_rate'], + 'apdex_score' => $app['application_summary']['apdex_score'], + 'host_count' => $app['application_summary']['host_count'], + 'instance_count' => $app['application_summary']['instance_count'], + ], + 'links' => [ + 'application_instances' => $app['links']['application_instances'] ?? [], + 'alert_policy' => $app['links']['alert_policy'], + 'server' => $app['links']['server'] ?? [], + ], + 'settings' => [ + 'app_apdex_threshold' => $app['settings']['app_apdex_threshold'], + 'end_user_apdex_threshold' => $app['settings']['end_user_apdex_threshold'], + 'enable_real_user_monitoring' => $app['settings']['enable_real_user_monitoring'], + 'use_server_side_config' => $app['settings']['use_server_side_config'], + ], + ], $data['applications'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get New Relic metrics. + * + * @param int $applicationId Application ID + * @param string $names Comma-separated metric names + * @param string $from Start time (ISO 8601) + * @param string $to End time (ISO 8601) + * @param string $values Comma-separated values (call_count, response_time, etc.) + * + * @return array{ + * metric_data: array{ + * from: string, + * to: string, + * metrics_not_found: array, + * metrics_found: array, + * metrics: array, + * }>, + * }>, + * }, + * }|string + */ + public function getMetrics( + int $applicationId, + string $names, + string $from, + string $to, + string $values = 'call_count,response_time,error_count', + ): array|string { + try { + $params = [ + 'names' => $names, + 'values' => $values, + 'from' => $from, + 'to' => $to, + 'summarize' => 'true', + ]; + + $response = $this->httpClient->request('GET', "https://api.newrelic.com/{$this->apiVersion}/applications/{$applicationId}/metrics/data.json", [ + 'headers' => [ + 'X-Api-Key' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting metrics: '.($data['error'] ?? 'Unknown error'); + } + + return [ + 'metric_data' => [ + 'from' => $data['metric_data']['from'], + 'to' => $data['metric_data']['to'], + 'metrics_not_found' => $data['metric_data']['metrics_not_found'] ?? [], + 'metrics_found' => $data['metric_data']['metrics_found'] ?? [], + 'metrics' => array_map(fn ($metric) => [ + 'name' => $metric['name'], + 'timeslices' => array_map(fn ($slice) => [ + 'from' => $slice['from'], + 'to' => $slice['to'], + 'values' => $slice['values'], + ], $metric['timeslices'] ?? []), + ], $data['metric_data']['metrics'] ?? []), + ], + ]; + } catch (\Exception $e) { + return 'Error getting metrics: '.$e->getMessage(); + } + } + + /** + * Get New Relic events. + * + * @param string $query NRQL query + * @param int $limit Number of events to retrieve + * + * @return array{ + * results: array>, + * metadata: array{ + * time_zone: string, + * raw_since: string, + * raw_until: string, + * raw_comparison_with: string, + * messages: array, + * contents: array{ + * function: string, + * limit: int, + * offset: int, + * order_by: string, + * }, + * }, + * }|string + */ + public function getEvents( + string $query, + int $limit = 100, + ): array|string { + try { + $payload = [ + 'query' => $query, + 'limit' => min(max($limit, 1), 1000), + ]; + + $response = $this->httpClient->request('POST', "https://api.newrelic.com/{$this->apiVersion}/graphql", [ + 'headers' => [ + 'X-Api-Key' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'query' => 'query($query: String!, $limit: Int!) { + actor { + nrql(query: $query, limit: $limit) { + results + metadata { + timeZone + rawSince + rawUntil + rawComparisonWith + messages + contents { + function + limit + offset + orderBy + } + } + } + } + }', + 'variables' => $payload, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error getting events: '.($data['errors'][0]['message'] ?? 'Unknown error'); + } + + $nrql = $data['data']['actor']['nrql']; + + return [ + 'results' => $nrql['results'] ?? [], + 'metadata' => [ + 'time_zone' => $nrql['metadata']['timeZone'], + 'raw_since' => $nrql['metadata']['rawSince'], + 'raw_until' => $nrql['metadata']['rawUntil'], + 'raw_comparison_with' => $nrql['metadata']['rawComparisonWith'], + 'messages' => $nrql['metadata']['messages'] ?? [], + 'contents' => [ + 'function' => $nrql['metadata']['contents']['function'], + 'limit' => $nrql['metadata']['contents']['limit'], + 'offset' => $nrql['metadata']['contents']['offset'], + 'order_by' => $nrql['metadata']['contents']['orderBy'], + ], + ], + ]; + } catch (\Exception $e) { + return 'Error getting events: '.$e->getMessage(); + } + } + + /** + * Get New Relic alerts. + * + * @param int $perPage Number of alerts per page + * @param int $page Page number + * @param string $filter Filter (all, open, closed, acknowledged) + * + * @return array, + * runbook_url: string, + * created_at_epoch_millis: int, + * updated_at_epoch_millis: int, + * state: string, + * source: string, + * violation_id: int, + * policy_id: int, + * condition_name: string, + * condition_id: int, + * policy_name: string, + * policy_url: string, + * runbook_url: string, + * incident_url: string, + * entity_guid: string, + * entity_name: string, + * entity_type: string, + * entity_alert_severity: string, + * entity_alert_priority: string, + * }> + */ + public function getAlerts( + int $perPage = 50, + int $page = 1, + string $filter = 'all', + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + 'filter[state]' => $filter, + ]; + + $response = $this->httpClient->request('GET', "https://api.newrelic.com/{$this->apiVersion}/alerts_violations.json", [ + 'headers' => [ + 'X-Api-Key' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($alert) => [ + 'id' => $alert['id'], + 'type' => $alert['type'], + 'incident_id' => $alert['incident_id'], + 'title' => $alert['title'], + 'body' => $alert['body'], + 'alert_url' => $alert['alert_url'], + 'entity_type' => $alert['entity_type'], + 'entity_group_id' => $alert['entity_group_id'], + 'entity_guid' => $alert['entity_guid'], + 'entity_name' => $alert['entity_name'], + 'priority' => $alert['priority'] ?? 'normal', + 'owner_user_id' => $alert['owner_user_id'], + 'notification_channel_ids' => $alert['notification_channel_ids'] ?? [], + 'runbook_url' => $alert['runbook_url'] ?? '', + 'created_at_epoch_millis' => $alert['created_at_epoch_millis'], + 'updated_at_epoch_millis' => $alert['updated_at_epoch_millis'], + 'state' => $alert['state'], + 'source' => $alert['source'], + 'violation_id' => $alert['violation_id'], + 'policy_id' => $alert['policy_id'], + 'condition_name' => $alert['condition_name'], + 'condition_id' => $alert['condition_id'], + 'policy_name' => $alert['policy_name'], + 'policy_url' => $alert['policy_url'], + 'incident_url' => $alert['incident_url'] ?? '', + 'entity_alert_severity' => $alert['entity_alert_severity'] ?? '', + 'entity_alert_priority' => $alert['entity_alert_priority'] ?? '', + ], $data['violations'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get New Relic deployments. + * + * @param int $applicationId Application ID + * @param int $perPage Number of deployments per page + * @param int $page Page number + * + * @return array + */ + public function getDeployments( + int $applicationId, + int $perPage = 50, + int $page = 1, + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + ]; + + $response = $this->httpClient->request('GET', "https://api.newrelic.com/{$this->apiVersion}/applications/{$applicationId}/deployments.json", [ + 'headers' => [ + 'X-Api-Key' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($deployment) => [ + 'id' => $deployment['id'], + 'revision' => $deployment['revision'], + 'changelog' => $deployment['changelog'], + 'description' => $deployment['description'], + 'user' => $deployment['user'], + 'timestamp' => $deployment['timestamp'], + 'application_id' => $deployment['application_id'], + 'links' => [ + 'application' => $deployment['links']['application'], + ], + ], $data['deployments'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get New Relic errors. + * + * @param int $applicationId Application ID + * @param string $from Start time (ISO 8601) + * @param string $to End time (ISO 8601) + * @param int $perPage Number of errors per page + * @param int $page Page number + * @param string $filter Filter (all, browser, mobile) + * + * @return array, + * application_id: int, + * links: array{ + * application: int, + * }, + * }> + */ + public function getErrors( + int $applicationId, + string $from, + string $to, + int $perPage = 50, + int $page = 1, + string $filter = 'all', + ): array { + try { + $params = [ + 'from' => $from, + 'to' => $to, + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + 'filter[name]' => $filter, + ]; + + $response = $this->httpClient->request('GET', "https://api.newrelic.com/{$this->apiVersion}/applications/{$applicationId}/errors.json", [ + 'headers' => [ + 'X-Api-Key' => $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($error) => [ + 'id' => $error['id'], + 'timestamp' => $error['timestamp'], + 'error_class' => $error['error_class'], + 'message' => $error['message'], + 'count' => $error['count'], + 'user_agent' => $error['user_agent'] ?? '', + 'host' => $error['host'] ?? '', + 'request_uri' => $error['request_uri'] ?? '', + 'request_method' => $error['request_method'] ?? '', + 'port' => $error['port'] ?? 0, + 'path' => $error['path'] ?? '', + 'stack_trace' => $error['stack_trace'] ?? [], + 'application_id' => $error['application_id'], + 'links' => [ + 'application' => $error['links']['application'], + ], + ], $data['errors'] ?? []); + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Nomad.php b/src/agent/src/Toolbox/Tool/Nomad.php new file mode 100644 index 000000000..1d8a9012e --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Nomad.php @@ -0,0 +1,643 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('nomad_get_jobs', 'Tool that gets Nomad jobs')] +#[AsTool('nomad_get_job', 'Tool that gets Nomad job details', method: 'getJob')] +#[AsTool('nomad_create_job', 'Tool that creates Nomad jobs', method: 'createJob')] +#[AsTool('nomad_update_job', 'Tool that updates Nomad jobs', method: 'updateJob')] +#[AsTool('nomad_delete_job', 'Tool that deletes Nomad jobs', method: 'deleteJob')] +#[AsTool('nomad_get_allocations', 'Tool that gets Nomad allocations', method: 'getAllocations')] +#[AsTool('nomad_get_nodes', 'Tool that gets Nomad nodes', method: 'getNodes')] +#[AsTool('nomad_get_evals', 'Tool that gets Nomad evaluations', method: 'getEvaluations')] +final readonly class Nomad +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $baseUrl = 'http://localhost:4646', + private string $region = 'global', + private string $namespace = 'default', + private array $options = [], + ) { + } + + /** + * Get Nomad jobs. + * + * @param string $prefix Job name prefix filter + * @param string $namespace Namespace filter + * + * @return array, + * children: array{ + * pending: int, + * running: int, + * dead: int, + * }, + * }, + * }> + */ + public function __invoke( + string $prefix = '', + string $namespace = '', + ): array { + try { + $params = []; + + if ($prefix) { + $params['prefix'] = $prefix; + } + + if ($namespace) { + $params['namespace'] = $namespace; + } else { + $params['namespace'] = $this->namespace; + } + + $params['region'] = $this->region; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/v1/jobs", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return array_map(fn ($job) => [ + 'id' => $job['ID'], + 'name' => $job['Name'], + 'namespace' => $job['Namespace'], + 'type' => $job['Type'], + 'priority' => $job['Priority'], + 'status' => $job['Status'], + 'statusDescription' => $job['StatusDescription'], + 'createIndex' => $job['CreateIndex'], + 'modifyIndex' => $job['ModifyIndex'], + 'jobSummary' => [ + 'jobId' => $job['JobSummary']['JobID'], + 'namespace' => $job['JobSummary']['Namespace'], + 'summary' => $job['JobSummary']['Summary'], + 'children' => [ + 'pending' => $job['JobSummary']['Children']['Pending'], + 'running' => $job['JobSummary']['Children']['Running'], + 'dead' => $job['JobSummary']['Children']['Dead'], + ], + ], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Nomad job details. + * + * @param string $jobId Job ID + * + * @return array{ + * id: string, + * name: string, + * namespace: string, + * type: string, + * priority: int, + * status: string, + * statusDescription: string, + * createIndex: int, + * modifyIndex: int, + * job: array{ + * id: string, + * name: string, + * namespace: string, + * type: string, + * priority: int, + * region: string, + * datacenters: array, + * taskGroups: array, + * resources: array{ + * cpu: int, + * memoryMB: int, + * diskMB: int, + * networks: array, + * }, + * }>, + * }>, + * }, + * }|string + */ + public function getJob(string $jobId): array|string + { + try { + $params = [ + 'region' => $this->region, + 'namespace' => $this->namespace, + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/v1/job/{$jobId}", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'id' => $data['ID'], + 'name' => $data['Name'], + 'namespace' => $data['Namespace'], + 'type' => $data['Type'], + 'priority' => $data['Priority'], + 'status' => $data['Status'], + 'statusDescription' => $data['StatusDescription'], + 'createIndex' => $data['CreateIndex'], + 'modifyIndex' => $data['ModifyIndex'], + 'job' => [ + 'id' => $data['Job']['ID'], + 'name' => $data['Job']['Name'], + 'namespace' => $data['Job']['Namespace'], + 'type' => $data['Job']['Type'], + 'priority' => $data['Job']['Priority'], + 'region' => $data['Job']['Region'], + 'datacenters' => $data['Job']['Datacenters'], + 'taskGroups' => array_map(fn ($group) => [ + 'name' => $group['Name'], + 'count' => $group['Count'], + 'tasks' => array_map(fn ($task) => [ + 'name' => $task['Name'], + 'driver' => $task['Driver'], + 'config' => $task['Config'], + 'resources' => [ + 'cpu' => $task['Resources']['CPU'], + 'memoryMB' => $task['Resources']['MemoryMB'], + 'diskMB' => $task['Resources']['DiskMB'], + 'networks' => array_map(fn ($network) => [ + 'device' => $network['Device'], + 'cidr' => $network['CIDR'], + 'ip' => $network['IP'], + 'mbits' => $network['MBits'], + ], $task['Resources']['Networks']), + ], + ], $group['Tasks']), + ], $data['Job']['TaskGroups']), + ], + ]; + } catch (\Exception $e) { + return 'Error getting job: '.$e->getMessage(); + } + } + + /** + * Create Nomad job. + * + * @param array $jobSpec Job specification + * @param string $evalPriority Evaluation priority + * + * @return array{ + * success: bool, + * evalId: string, + * jobModifyIndex: int, + * warnings: string, + * error: string, + * } + */ + public function createJob( + array $jobSpec, + string $evalPriority = '', + ): array { + try { + $params = [ + 'region' => $this->region, + 'namespace' => $this->namespace, + ]; + + if ($evalPriority) { + $params['eval_priority'] = $evalPriority; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/jobs", [ + 'query' => array_merge($this->options, $params), + 'json' => $jobSpec, + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'evalId' => $data['EvalID'], + 'jobModifyIndex' => $data['JobModifyIndex'], + 'warnings' => $data['Warnings'] ?? '', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'evalId' => '', + 'jobModifyIndex' => 0, + 'warnings' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Update Nomad job. + * + * @param string $jobId Job ID + * @param array $jobSpec Updated job specification + * @param string $evalPriority Evaluation priority + * + * @return array{ + * success: bool, + * evalId: string, + * jobModifyIndex: int, + * warnings: string, + * error: string, + * } + */ + public function updateJob( + string $jobId, + array $jobSpec, + string $evalPriority = '', + ): array { + try { + $params = [ + 'region' => $this->region, + 'namespace' => $this->namespace, + ]; + + if ($evalPriority) { + $params['eval_priority'] = $evalPriority; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/job/{$jobId}", [ + 'query' => array_merge($this->options, $params), + 'json' => $jobSpec, + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'evalId' => $data['EvalID'], + 'jobModifyIndex' => $data['JobModifyIndex'], + 'warnings' => $data['Warnings'] ?? '', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'evalId' => '', + 'jobModifyIndex' => 0, + 'warnings' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Delete Nomad job. + * + * @param string $jobId Job ID + * @param string $purge Purge job completely + * + * @return array{ + * success: bool, + * evalId: string, + * jobModifyIndex: int, + * error: string, + * } + */ + public function deleteJob( + string $jobId, + string $purge = 'false', + ): array { + try { + $params = [ + 'region' => $this->region, + 'namespace' => $this->namespace, + ]; + + if ('true' === $purge) { + $params['purge'] = 'true'; + } + + $response = $this->httpClient->request('DELETE', "{$this->baseUrl}/v1/job/{$jobId}", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'evalId' => $data['EvalID'], + 'jobModifyIndex' => $data['JobModifyIndex'], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'evalId' => '', + 'jobModifyIndex' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Nomad allocations. + * + * @param string $jobId Job ID filter + * @param string $namespace Namespace filter + * + * @return array, + * }> + */ + public function getAllocations( + string $jobId = '', + string $namespace = '', + ): array { + try { + $params = []; + + if ($jobId) { + $params['job'] = $jobId; + } + + if ($namespace) { + $params['namespace'] = $namespace; + } else { + $params['namespace'] = $this->namespace; + } + + $params['region'] = $this->region; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/v1/allocations", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return array_map(fn ($allocation) => [ + 'id' => $allocation['ID'], + 'evalId' => $allocation['EvalID'], + 'name' => $allocation['Name'], + 'namespace' => $allocation['Namespace'], + 'nodeId' => $allocation['NodeID'], + 'jobId' => $allocation['JobID'], + 'jobType' => $allocation['JobType'], + 'taskGroup' => $allocation['TaskGroup'], + 'desiredStatus' => $allocation['DesiredStatus'], + 'desiredDescription' => $allocation['DesiredDescription'], + 'clientStatus' => $allocation['ClientStatus'], + 'clientDescription' => $allocation['ClientDescription'], + 'createIndex' => $allocation['CreateIndex'], + 'modifyIndex' => $allocation['ModifyIndex'], + 'taskStates' => array_map(fn ($taskState) => [ + 'state' => $taskState['State'], + 'failed' => $taskState['Failed'], + 'restarts' => $taskState['Restarts'], + 'lastRestart' => $taskState['LastRestart'], + 'startedAt' => $taskState['StartedAt'], + 'finishedAt' => $taskState['FinishedAt'], + ], $allocation['TaskStates']), + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Nomad nodes. + * + * @param string $prefix Node name prefix filter + * + * @return array, + * }, + * reservedResources: array{ + * cpu: int, + * memoryMB: int, + * diskMB: int, + * networks: array, + * }, + * }> + */ + public function getNodes(string $prefix = ''): array + { + try { + $params = [ + 'region' => $this->region, + ]; + + if ($prefix) { + $params['prefix'] = $prefix; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/v1/nodes", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return array_map(fn ($node) => [ + 'id' => $node['ID'], + 'name' => $node['Name'], + 'datacenter' => $node['Datacenter'], + 'nodeClass' => $node['NodeClass'], + 'drain' => $node['Drain'], + 'status' => $node['Status'], + 'statusDescription' => $node['StatusDescription'], + 'createIndex' => $node['CreateIndex'], + 'modifyIndex' => $node['ModifyIndex'], + 'nodeResources' => [ + 'cpu' => $node['NodeResources']['CPU'], + 'memoryMB' => $node['NodeResources']['MemoryMB'], + 'diskMB' => $node['NodeResources']['DiskMB'], + 'networks' => array_map(fn ($network) => [ + 'device' => $network['Device'], + 'cidr' => $network['CIDR'], + 'ip' => $network['IP'], + 'mbits' => $network['MBits'], + ], $node['NodeResources']['Networks']), + ], + 'reservedResources' => [ + 'cpu' => $node['ReservedResources']['CPU'], + 'memoryMB' => $node['ReservedResources']['MemoryMB'], + 'diskMB' => $node['ReservedResources']['DiskMB'], + 'networks' => array_map(fn ($network) => [ + 'device' => $network['Device'], + 'cidr' => $network['CIDR'], + 'ip' => $network['IP'], + 'mbits' => $network['MBits'], + ], $node['ReservedResources']['Networks']), + ], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Nomad evaluations. + * + * @param string $jobId Job ID filter + * @param string $namespace Namespace filter + * + * @return array + */ + public function getEvaluations( + string $jobId = '', + string $namespace = '', + ): array { + try { + $params = []; + + if ($jobId) { + $params['job'] = $jobId; + } + + if ($namespace) { + $params['namespace'] = $namespace; + } else { + $params['namespace'] = $this->namespace; + } + + $params['region'] = $this->region; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/v1/evaluations", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return array_map(fn ($eval) => [ + 'id' => $eval['ID'], + 'priority' => $eval['Priority'], + 'type' => $eval['Type'], + 'triggeredBy' => $eval['TriggeredBy'], + 'jobId' => $eval['JobID'], + 'jobModifyIndex' => $eval['JobModifyIndex'], + 'nodeId' => $eval['NodeID'], + 'nodeModifyIndex' => $eval['NodeModifyIndex'], + 'status' => $eval['Status'], + 'statusDescription' => $eval['StatusDescription'], + 'wait' => $eval['Wait'], + 'waitUntil' => $eval['WaitUntil'], + 'nextEval' => $eval['NextEval'], + 'previousEval' => $eval['PreviousEval'], + 'blockedEval' => $eval['BlockedEval'], + 'createIndex' => $eval['CreateIndex'], + 'modifyIndex' => $eval['ModifyIndex'], + ], $data); + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Notion.php b/src/agent/src/Toolbox/Tool/Notion.php new file mode 100644 index 000000000..13f93af88 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Notion.php @@ -0,0 +1,555 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('notion_create_page', 'Tool that creates Notion pages')] +#[AsTool('notion_get_page', 'Tool that gets Notion pages', method: 'getPage')] +#[AsTool('notion_update_page', 'Tool that updates Notion pages', method: 'updatePage')] +#[AsTool('notion_query_database', 'Tool that queries Notion databases', method: 'queryDatabase')] +#[AsTool('notion_create_database_entry', 'Tool that creates Notion database entries', method: 'createDatabaseEntry')] +#[AsTool('notion_search', 'Tool that searches Notion content', method: 'search')] +final readonly class Notion +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private string $apiVersion = '2022-06-28', + private array $options = [], + ) { + } + + /** + * Create a Notion page. + * + * @param string $parentId Parent page or database ID + * @param string $title Page title + * @param string $content Page content (markdown) + * @param array $properties Page properties + * + * @return array{ + * id: string, + * object: string, + * created_time: string, + * last_edited_time: string, + * parent: array{type: string, page_id: string}|array{type: string, database_id: string}, + * properties: array, + * url: string, + * }|string + */ + public function __invoke( + string $parentId, + string $title, + string $content = '', + array $properties = [], + ): array|string { + try { + $payload = [ + 'parent' => [ + 'page_id' => $parentId, + ], + 'properties' => array_merge([ + 'title' => [ + 'title' => [ + [ + 'text' => [ + 'content' => $title, + ], + ], + ], + ], + ], $properties), + ]; + + if ($content) { + $payload['children'] = $this->convertMarkdownToBlocks($content); + } + + $response = $this->httpClient->request('POST', 'https://api.notion.com/v1/pages', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + 'Notion-Version' => $this->apiVersion, + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating page: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'object' => $data['object'], + 'created_time' => $data['created_time'], + 'last_edited_time' => $data['last_edited_time'], + 'parent' => $data['parent'], + 'properties' => $data['properties'], + 'url' => $data['url'], + ]; + } catch (\Exception $e) { + return 'Error creating page: '.$e->getMessage(); + } + } + + /** + * Get a Notion page. + * + * @param string $pageId Page ID + * + * @return array{ + * id: string, + * object: string, + * created_time: string, + * last_edited_time: string, + * parent: array{type: string, page_id: string}|array{type: string, database_id: string}, + * properties: array, + * url: string, + * }|string + */ + public function getPage(string $pageId): array|string + { + try { + $response = $this->httpClient->request('GET', "https://api.notion.com/v1/pages/{$pageId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + 'Notion-Version' => $this->apiVersion, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting page: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'object' => $data['object'], + 'created_time' => $data['created_time'], + 'last_edited_time' => $data['last_edited_time'], + 'parent' => $data['parent'], + 'properties' => $data['properties'], + 'url' => $data['url'], + ]; + } catch (\Exception $e) { + return 'Error getting page: '.$e->getMessage(); + } + } + + /** + * Update a Notion page. + * + * @param string $pageId Page ID + * @param array $properties Properties to update + * + * @return array{ + * id: string, + * object: string, + * created_time: string, + * last_edited_time: string, + * parent: array{type: string, page_id: string}|array{type: string, database_id: string}, + * properties: array, + * url: string, + * }|string + */ + public function updatePage( + string $pageId, + array $properties, + ): array|string { + try { + $payload = [ + 'properties' => $properties, + ]; + + $response = $this->httpClient->request('PATCH', "https://api.notion.com/v1/pages/{$pageId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + 'Notion-Version' => $this->apiVersion, + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error updating page: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'object' => $data['object'], + 'created_time' => $data['created_time'], + 'last_edited_time' => $data['last_edited_time'], + 'parent' => $data['parent'], + 'properties' => $data['properties'], + 'url' => $data['url'], + ]; + } catch (\Exception $e) { + return 'Error updating page: '.$e->getMessage(); + } + } + + /** + * Query a Notion database. + * + * @param string $databaseId Database ID + * @param array $filter Database filter + * @param array $sorts Sort criteria + * @param int $pageSize Number of results per page + * @param string $startCursor Pagination cursor + * + * @return array{ + * results: array, + * url: string, + * }>, + * next_cursor: string|null, + * has_more: bool, + * }|string + */ + public function queryDatabase( + string $databaseId, + array $filter = [], + array $sorts = [], + int $pageSize = 100, + string $startCursor = '', + ): array|string { + try { + $payload = [ + 'page_size' => min(max($pageSize, 1), 100), + ]; + + if (!empty($filter)) { + $payload['filter'] = $filter; + } + + if (!empty($sorts)) { + $payload['sorts'] = $sorts; + } + + if ($startCursor) { + $payload['start_cursor'] = $startCursor; + } + + $response = $this->httpClient->request('POST', "https://api.notion.com/v1/databases/{$databaseId}/query", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + 'Notion-Version' => $this->apiVersion, + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error querying database: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'results' => array_map(fn ($result) => [ + 'id' => $result['id'], + 'object' => $result['object'], + 'created_time' => $result['created_time'], + 'last_edited_time' => $result['last_edited_time'], + 'parent' => $result['parent'], + 'properties' => $result['properties'], + 'url' => $result['url'], + ], $data['results']), + 'next_cursor' => $data['next_cursor'] ?? null, + 'has_more' => $data['has_more'], + ]; + } catch (\Exception $e) { + return 'Error querying database: '.$e->getMessage(); + } + } + + /** + * Create a Notion database entry. + * + * @param string $databaseId Database ID + * @param array $properties Entry properties + * + * @return array{ + * id: string, + * object: string, + * created_time: string, + * last_edited_time: string, + * parent: array{type: string, database_id: string}, + * properties: array, + * url: string, + * }|string + */ + public function createDatabaseEntry( + string $databaseId, + array $properties, + ): array|string { + try { + $payload = [ + 'parent' => [ + 'database_id' => $databaseId, + ], + 'properties' => $properties, + ]; + + $response = $this->httpClient->request('POST', 'https://api.notion.com/v1/pages', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + 'Notion-Version' => $this->apiVersion, + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating database entry: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'object' => $data['object'], + 'created_time' => $data['created_time'], + 'last_edited_time' => $data['last_edited_time'], + 'parent' => $data['parent'], + 'properties' => $data['properties'], + 'url' => $data['url'], + ]; + } catch (\Exception $e) { + return 'Error creating database entry: '.$e->getMessage(); + } + } + + /** + * Search Notion content. + * + * @param string $query Search query + * @param string $filter Filter by object type (page, database) + * @param string $sort Sort criteria + * @param int $pageSize Number of results per page + * @param string $startCursor Pagination cursor + * + * @return array{ + * results: array, + * properties: array|null, + * url: string, + * title: string, + * }>, + * next_cursor: string|null, + * has_more: bool, + * }|string + */ + public function search( + string $query, + string $filter = '', + string $sort = '', + int $pageSize = 100, + string $startCursor = '', + ): array|string { + try { + $payload = [ + 'query' => $query, + 'page_size' => min(max($pageSize, 1), 100), + ]; + + if ($filter) { + $payload['filter'] = ['property' => 'object', 'value' => $filter]; + } + + if ($sort) { + $payload['sort'] = ['direction' => $sort, 'timestamp' => 'last_edited_time']; + } + + if ($startCursor) { + $payload['start_cursor'] = $startCursor; + } + + $response = $this->httpClient->request('POST', 'https://api.notion.com/v1/search', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + 'Notion-Version' => $this->apiVersion, + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error searching: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'results' => array_map(fn ($result) => [ + 'id' => $result['id'], + 'object' => $result['object'], + 'created_time' => $result['created_time'], + 'last_edited_time' => $result['last_edited_time'], + 'parent' => $result['parent'], + 'properties' => $result['properties'] ?? null, + 'url' => $result['url'], + 'title' => $this->extractTitle($result), + ], $data['results']), + 'next_cursor' => $data['next_cursor'] ?? null, + 'has_more' => $data['has_more'], + ]; + } catch (\Exception $e) { + return 'Error searching: '.$e->getMessage(); + } + } + + /** + * Convert markdown to Notion blocks. + * + * @param string $markdown Markdown content + * + * @return array}}> + */ + private function convertMarkdownToBlocks(string $markdown): array + { + $lines = explode("\n", $markdown); + $blocks = []; + + foreach ($lines as $line) { + $line = trim($line); + if (empty($line)) { + continue; + } + + // Simple markdown to Notion block conversion + if (str_starts_with($line, '# ')) { + $blocks[] = [ + 'type' => 'heading_1', + 'heading_1' => [ + 'rich_text' => [ + [ + 'type' => 'text', + 'text' => ['content' => substr($line, 2)], + ], + ], + ], + ]; + } elseif (str_starts_with($line, '## ')) { + $blocks[] = [ + 'type' => 'heading_2', + 'heading_2' => [ + 'rich_text' => [ + [ + 'type' => 'text', + 'text' => ['content' => substr($line, 3)], + ], + ], + ], + ]; + } elseif (str_starts_with($line, '### ')) { + $blocks[] = [ + 'type' => 'heading_3', + 'heading_3' => [ + 'rich_text' => [ + [ + 'type' => 'text', + 'text' => ['content' => substr($line, 4)], + ], + ], + ], + ]; + } elseif (str_starts_with($line, '- ') || str_starts_with($line, '* ')) { + $blocks[] = [ + 'type' => 'bulleted_list_item', + 'bulleted_list_item' => [ + 'rich_text' => [ + [ + 'type' => 'text', + 'text' => ['content' => substr($line, 2)], + ], + ], + ], + ]; + } elseif (str_starts_with($line, '1. ')) { + $blocks[] = [ + 'type' => 'numbered_list_item', + 'numbered_list_item' => [ + 'rich_text' => [ + [ + 'type' => 'text', + 'text' => ['content' => substr($line, 3)], + ], + ], + ], + ]; + } else { + $blocks[] = [ + 'type' => 'paragraph', + 'paragraph' => [ + 'rich_text' => [ + [ + 'type' => 'text', + 'text' => ['content' => $line], + ], + ], + ], + ]; + } + } + + return $blocks; + } + + /** + * Extract title from Notion object. + * + * @param array $object Notion object + */ + private function extractTitle(array $object): string + { + if (isset($object['properties']['title']['title'][0]['text']['content'])) { + return $object['properties']['title']['title'][0]['text']['content']; + } + + if (isset($object['properties']['Name']['title'][0]['text']['content'])) { + return $object['properties']['Name']['title'][0]['text']['content']; + } + + // Try to find any title property + foreach ($object['properties'] ?? [] as $property) { + if (isset($property['title'][0]['text']['content'])) { + return $property['title'][0]['text']['content']; + } + } + + return 'Untitled'; + } +} diff --git a/src/agent/src/Toolbox/Tool/Nuclia.php b/src/agent/src/Toolbox/Tool/Nuclia.php new file mode 100644 index 000000000..48e3d16f9 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Nuclia.php @@ -0,0 +1,748 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('nuclia_upload', 'Tool that uploads documents to Nuclia knowledge base')] +#[AsTool('nuclia_search', 'Tool that searches Nuclia knowledge base', method: 'search')] +#[AsTool('nuclia_get_resource', 'Tool that retrieves resources from Nuclia', method: 'getResource')] +#[AsTool('nuclia_create_knowledge_box', 'Tool that creates knowledge boxes', method: 'createKnowledgeBox')] +#[AsTool('nuclia_list_knowledge_boxes', 'Tool that lists knowledge boxes', method: 'listKnowledgeBoxes')] +#[AsTool('nuclia_delete_resource', 'Tool that deletes resources', method: 'deleteResource')] +#[AsTool('nuclia_extract_text', 'Tool that extracts text from documents', method: 'extractText')] +#[AsTool('nuclia_generate_answer', 'Tool that generates answers from knowledge base', method: 'generateAnswer')] +final readonly class Nuclia +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://nuclia.cloud/api/v1', + private array $options = [], + ) { + } + + /** + * Upload documents to Nuclia knowledge base. + * + * @param string $knowledgeBoxId Knowledge box ID + * @param string $filePath Path to file to upload + * @param string $title Document title + * @param string $summary Document summary + * @param array $metadata Additional metadata + * + * @return array{ + * success: bool, + * upload: array{ + * knowledge_box_id: string, + * resource_id: string, + * title: string, + * summary: string, + * file_path: string, + * file_size: int, + * upload_status: string, + * processing_status: string, + * extracted_text: string, + * entities: array, + * metadata: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $knowledgeBoxId, + string $filePath, + string $title, + string $summary = '', + array $metadata = [], + ): array { + try { + if (!file_exists($filePath)) { + throw new \Exception("File not found: {$filePath}."); + } + + $fileContent = file_get_contents($filePath); + $fileName = basename($filePath); + $fileSize = \strlen($fileContent); + + $requestData = [ + 'title' => $title, + 'summary' => $summary, + 'metadata' => $metadata, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/kb/{$knowledgeBoxId}/upload", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'multipart/form-data', + ], + 'body' => [ + 'file' => $fileContent, + 'filename' => $fileName, + 'data' => json_encode($requestData), + ], + ] + $this->options); + + $data = $response->toArray(); + $upload = $data['upload'] ?? []; + + return [ + 'success' => true, + 'upload' => [ + 'knowledge_box_id' => $knowledgeBoxId, + 'resource_id' => $upload['resource_id'] ?? '', + 'title' => $title, + 'summary' => $summary, + 'file_path' => $filePath, + 'file_size' => $fileSize, + 'upload_status' => $upload['upload_status'] ?? 'uploaded', + 'processing_status' => $upload['processing_status'] ?? 'processing', + 'extracted_text' => $upload['extracted_text'] ?? '', + 'entities' => array_map(fn ($entity) => [ + 'label' => $entity['label'] ?? '', + 'text' => $entity['text'] ?? '', + 'start' => $entity['start'] ?? 0, + 'end' => $entity['end'] ?? 0, + ], $upload['entities'] ?? []), + 'metadata' => $metadata, + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'upload' => [ + 'knowledge_box_id' => $knowledgeBoxId, + 'resource_id' => '', + 'title' => $title, + 'summary' => $summary, + 'file_path' => $filePath, + 'file_size' => 0, + 'upload_status' => 'failed', + 'processing_status' => 'failed', + 'extracted_text' => '', + 'entities' => [], + 'metadata' => $metadata, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search Nuclia knowledge base. + * + * @param string $knowledgeBoxId Knowledge box ID + * @param string $query Search query + * @param int $pageSize Number of results per page + * @param int $pageNumber Page number + * @param array $filters Search filters + * + * @return array{ + * success: bool, + * search: array{ + * knowledge_box_id: string, + * query: string, + * results: array, + * entities: array, + * }>, + * total_results: int, + * page_size: int, + * page_number: int, + * total_pages: int, + * }, + * processingTime: float, + * error: string, + * } + */ + public function search( + string $knowledgeBoxId, + string $query, + int $pageSize = 10, + int $pageNumber = 1, + array $filters = [], + ): array { + try { + $requestData = [ + 'query' => $query, + 'page_size' => max(1, min($pageSize, 100)), + 'page_number' => max(1, $pageNumber), + 'filters' => $filters, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/kb/{$knowledgeBoxId}/search", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $search = $data['search'] ?? []; + + return [ + 'success' => true, + 'search' => [ + 'knowledge_box_id' => $knowledgeBoxId, + 'query' => $query, + 'results' => array_map(fn ($result) => [ + 'resource_id' => $result['resource_id'] ?? '', + 'title' => $result['title'] ?? '', + 'summary' => $result['summary'] ?? '', + 'score' => $result['score'] ?? 0.0, + 'text' => $result['text'] ?? '', + 'metadata' => $result['metadata'] ?? [], + 'entities' => array_map(fn ($entity) => [ + 'label' => $entity['label'] ?? '', + 'text' => $entity['text'] ?? '', + 'start' => $entity['start'] ?? 0, + 'end' => $entity['end'] ?? 0, + ], $result['entities'] ?? []), + ], $search['results'] ?? []), + 'total_results' => $search['total_results'] ?? 0, + 'page_size' => $pageSize, + 'page_number' => $pageNumber, + 'total_pages' => $search['total_pages'] ?? 0, + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'search' => [ + 'knowledge_box_id' => $knowledgeBoxId, + 'query' => $query, + 'results' => [], + 'total_results' => 0, + 'page_size' => $pageSize, + 'page_number' => $pageNumber, + 'total_pages' => 0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get resource from Nuclia. + * + * @param string $knowledgeBoxId Knowledge box ID + * @param string $resourceId Resource ID + * @param bool $includeText Whether to include extracted text + * @param bool $includeEntities Whether to include entities + * + * @return array{ + * success: bool, + * resource: array{ + * resource_id: string, + * title: string, + * summary: string, + * created_at: string, + * updated_at: string, + * file_type: string, + * file_size: int, + * text: string, + * entities: array, + * metadata: array, + * status: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function getResource( + string $knowledgeBoxId, + string $resourceId, + bool $includeText = true, + bool $includeEntities = true, + ): array { + try { + $response = $this->httpClient->request('GET', "{$this->baseUrl}/kb/{$knowledgeBoxId}/resource/{$resourceId}", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + ], + 'query' => [ + 'include_text' => $includeText ? 'true' : 'false', + 'include_entities' => $includeEntities ? 'true' : 'false', + ], + ] + $this->options); + + $data = $response->toArray(); + $resource = $data['resource'] ?? []; + + return [ + 'success' => true, + 'resource' => [ + 'resource_id' => $resourceId, + 'title' => $resource['title'] ?? '', + 'summary' => $resource['summary'] ?? '', + 'created_at' => $resource['created_at'] ?? '', + 'updated_at' => $resource['updated_at'] ?? '', + 'file_type' => $resource['file_type'] ?? '', + 'file_size' => $resource['file_size'] ?? 0, + 'text' => $includeText ? ($resource['text'] ?? '') : '', + 'entities' => $includeEntities ? array_map(fn ($entity) => [ + 'label' => $entity['label'] ?? '', + 'text' => $entity['text'] ?? '', + 'start' => $entity['start'] ?? 0, + 'end' => $entity['end'] ?? 0, + 'confidence' => $entity['confidence'] ?? 0.0, + ], $resource['entities'] ?? []) : [], + 'metadata' => $resource['metadata'] ?? [], + 'status' => $resource['status'] ?? 'unknown', + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'resource' => [ + 'resource_id' => $resourceId, + 'title' => '', + 'summary' => '', + 'created_at' => '', + 'updated_at' => '', + 'file_type' => '', + 'file_size' => 0, + 'text' => '', + 'entities' => [], + 'metadata' => [], + 'status' => 'unknown', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create knowledge box. + * + * @param string $title Knowledge box title + * @param string $description Knowledge box description + * @param array $settings Knowledge box settings + * + * @return array{ + * success: bool, + * knowledge_box: array{ + * id: string, + * title: string, + * description: string, + * created_at: string, + * settings: array, + * status: string, + * resource_count: int, + * }, + * processingTime: float, + * error: string, + * } + */ + public function createKnowledgeBox( + string $title, + string $description = '', + array $settings = [], + ): array { + try { + $requestData = [ + 'title' => $title, + 'description' => $description, + 'settings' => $settings, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/kb", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $knowledgeBox = $data['knowledge_box'] ?? []; + + return [ + 'success' => true, + 'knowledge_box' => [ + 'id' => $knowledgeBox['id'] ?? '', + 'title' => $title, + 'description' => $description, + 'created_at' => $knowledgeBox['created_at'] ?? '', + 'settings' => $settings, + 'status' => $knowledgeBox['status'] ?? 'active', + 'resource_count' => $knowledgeBox['resource_count'] ?? 0, + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'knowledge_box' => [ + 'id' => '', + 'title' => $title, + 'description' => $description, + 'created_at' => '', + 'settings' => $settings, + 'status' => 'failed', + 'resource_count' => 0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * List knowledge boxes. + * + * @param int $pageSize Number of results per page + * @param int $pageNumber Page number + * + * @return array{ + * success: bool, + * knowledge_boxes: array, + * total_count: int, + * page_size: int, + * page_number: int, + * total_pages: int, + * processingTime: float, + * error: string, + * } + */ + public function listKnowledgeBoxes( + int $pageSize = 20, + int $pageNumber = 1, + ): array { + try { + $response = $this->httpClient->request('GET', "{$this->baseUrl}/kb", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + ], + 'query' => [ + 'page_size' => max(1, min($pageSize, 100)), + 'page_number' => max(1, $pageNumber), + ], + ] + $this->options); + + $data = $response->toArray(); + $knowledgeBoxes = $data['knowledge_boxes'] ?? []; + + return [ + 'success' => true, + 'knowledge_boxes' => array_map(fn ($kb) => [ + 'id' => $kb['id'] ?? '', + 'title' => $kb['title'] ?? '', + 'description' => $kb['description'] ?? '', + 'created_at' => $kb['created_at'] ?? '', + 'resource_count' => $kb['resource_count'] ?? 0, + 'status' => $kb['status'] ?? 'unknown', + ], $knowledgeBoxes), + 'total_count' => $data['total_count'] ?? 0, + 'page_size' => $pageSize, + 'page_number' => $pageNumber, + 'total_pages' => $data['total_pages'] ?? 0, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'knowledge_boxes' => [], + 'total_count' => 0, + 'page_size' => $pageSize, + 'page_number' => $pageNumber, + 'total_pages' => 0, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Delete resource from Nuclia. + * + * @param string $knowledgeBoxId Knowledge box ID + * @param string $resourceId Resource ID to delete + * + * @return array{ + * success: bool, + * deletion: array{ + * knowledge_box_id: string, + * resource_id: string, + * deleted_at: string, + * status: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function deleteResource( + string $knowledgeBoxId, + string $resourceId, + ): array { + try { + $response = $this->httpClient->request('DELETE', "{$this->baseUrl}/kb/{$knowledgeBoxId}/resource/{$resourceId}", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + ], + ] + $this->options); + + $data = $response->toArray(); + $deletion = $data['deletion'] ?? []; + + return [ + 'success' => true, + 'deletion' => [ + 'knowledge_box_id' => $knowledgeBoxId, + 'resource_id' => $resourceId, + 'deleted_at' => $deletion['deleted_at'] ?? '', + 'status' => 'deleted', + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'deletion' => [ + 'knowledge_box_id' => $knowledgeBoxId, + 'resource_id' => $resourceId, + 'deleted_at' => '', + 'status' => 'failed', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Extract text from documents. + * + * @param string $knowledgeBoxId Knowledge box ID + * @param string $resourceId Resource ID + * @param string $textType Type of text to extract (full, summary, entities) + * + * @return array{ + * success: bool, + * extraction: array{ + * resource_id: string, + * text_type: string, + * extracted_text: string, + * entities: array, + * summary: string, + * word_count: int, + * character_count: int, + * }, + * processingTime: float, + * error: string, + * } + */ + public function extractText( + string $knowledgeBoxId, + string $resourceId, + string $textType = 'full', + ): array { + try { + $response = $this->httpClient->request('GET', "{$this->baseUrl}/kb/{$knowledgeBoxId}/resource/{$resourceId}/text", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + ], + 'query' => [ + 'text_type' => $textType, + ], + ] + $this->options); + + $data = $response->toArray(); + $extraction = $data['extraction'] ?? []; + + return [ + 'success' => true, + 'extraction' => [ + 'resource_id' => $resourceId, + 'text_type' => $textType, + 'extracted_text' => $extraction['extracted_text'] ?? '', + 'entities' => array_map(fn ($entity) => [ + 'label' => $entity['label'] ?? '', + 'text' => $entity['text'] ?? '', + 'start' => $entity['start'] ?? 0, + 'end' => $entity['end'] ?? 0, + 'confidence' => $entity['confidence'] ?? 0.0, + ], $extraction['entities'] ?? []), + 'summary' => $extraction['summary'] ?? '', + 'word_count' => $extraction['word_count'] ?? 0, + 'character_count' => $extraction['character_count'] ?? 0, + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'extraction' => [ + 'resource_id' => $resourceId, + 'text_type' => $textType, + 'extracted_text' => '', + 'entities' => [], + 'summary' => '', + 'word_count' => 0, + 'character_count' => 0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Generate answer from knowledge base. + * + * @param string $knowledgeBoxId Knowledge box ID + * @param string $question Question to answer + * @param int $maxResults Maximum number of results to consider + * @param float $confidenceThreshold Minimum confidence threshold + * + * @return array{ + * success: bool, + * answer: array{ + * question: string, + * answer: string, + * confidence: float, + * sources: array, + * entities: array, + * reasoning: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function generateAnswer( + string $knowledgeBoxId, + string $question, + int $maxResults = 5, + float $confidenceThreshold = 0.7, + ): array { + try { + $requestData = [ + 'question' => $question, + 'max_results' => max(1, min($maxResults, 20)), + 'confidence_threshold' => max(0.0, min($confidenceThreshold, 1.0)), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/kb/{$knowledgeBoxId}/answer", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $answer = $data['answer'] ?? []; + + return [ + 'success' => true, + 'answer' => [ + 'question' => $question, + 'answer' => $answer['answer'] ?? '', + 'confidence' => $answer['confidence'] ?? 0.0, + 'sources' => array_map(fn ($source) => [ + 'resource_id' => $source['resource_id'] ?? '', + 'title' => $source['title'] ?? '', + 'score' => $source['score'] ?? 0.0, + 'text' => $source['text'] ?? '', + ], $answer['sources'] ?? []), + 'entities' => array_map(fn ($entity) => [ + 'label' => $entity['label'] ?? '', + 'text' => $entity['text'] ?? '', + 'confidence' => $entity['confidence'] ?? 0.0, + ], $answer['entities'] ?? []), + 'reasoning' => $answer['reasoning'] ?? '', + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'answer' => [ + 'question' => $question, + 'answer' => '', + 'confidence' => 0.0, + 'sources' => [], + 'entities' => [], + 'reasoning' => '', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Office365.php b/src/agent/src/Toolbox/Tool/Office365.php new file mode 100644 index 000000000..ee5984b99 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Office365.php @@ -0,0 +1,1002 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('office365_get_user_profile', 'Tool that gets Office 365 user profile')] +#[AsTool('office365_send_email', 'Tool that sends email via Office 365', method: 'sendEmail')] +#[AsTool('office365_get_emails', 'Tool that gets emails from Office 365', method: 'getEmails')] +#[AsTool('office365_get_calendar_events', 'Tool that gets calendar events', method: 'getCalendarEvents')] +#[AsTool('office365_create_calendar_event', 'Tool that creates calendar events', method: 'createCalendarEvent')] +#[AsTool('office365_get_drive_files', 'Tool that gets OneDrive files', method: 'getDriveFiles')] +#[AsTool('office365_upload_file', 'Tool that uploads files to OneDrive', method: 'uploadFile')] +#[AsTool('office365_get_teams', 'Tool that gets Microsoft Teams', method: 'getTeams')] +final readonly class Office365 +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $accessToken, + private string $baseUrl = 'https://graph.microsoft.com/v1.0', + private array $options = [], + ) { + } + + /** + * Get Office 365 user profile. + * + * @param string $userId User ID (empty for current user) + * + * @return array{ + * success: bool, + * user: array{ + * id: string, + * displayName: string, + * givenName: string, + * surname: string, + * userPrincipalName: string, + * mail: string, + * jobTitle: string, + * officeLocation: string, + * mobilePhone: string, + * businessPhones: array, + * department: string, + * companyName: string, + * country: string, + * city: string, + * state: string, + * streetAddress: string, + * postalCode: string, + * preferredLanguage: string, + * accountEnabled: bool, + * createdDateTime: string, + * lastPasswordChangeDateTime: string, + * userType: string, + * userRoles: array, + * }, + * error: string, + * } + */ + public function __invoke(string $userId = ''): array + { + try { + $endpoint = $userId ? "/users/{$userId}" : '/me'; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}{$endpoint}", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'user' => [ + 'id' => $data['id'] ?? '', + 'displayName' => $data['displayName'] ?? '', + 'givenName' => $data['givenName'] ?? '', + 'surname' => $data['surname'] ?? '', + 'userPrincipalName' => $data['userPrincipalName'] ?? '', + 'mail' => $data['mail'] ?? '', + 'jobTitle' => $data['jobTitle'] ?? '', + 'officeLocation' => $data['officeLocation'] ?? '', + 'mobilePhone' => $data['mobilePhone'] ?? '', + 'businessPhones' => $data['businessPhones'] ?? [], + 'department' => $data['department'] ?? '', + 'companyName' => $data['companyName'] ?? '', + 'country' => $data['country'] ?? '', + 'city' => $data['city'] ?? '', + 'state' => $data['state'] ?? '', + 'streetAddress' => $data['streetAddress'] ?? '', + 'postalCode' => $data['postalCode'] ?? '', + 'preferredLanguage' => $data['preferredLanguage'] ?? '', + 'accountEnabled' => $data['accountEnabled'] ?? false, + 'createdDateTime' => $data['createdDateTime'] ?? '', + 'lastPasswordChangeDateTime' => $data['lastPasswordChangeDateTime'] ?? '', + 'userType' => $data['userType'] ?? '', + 'userRoles' => $data['userRoles'] ?? [], + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'user' => [ + 'id' => '', + 'displayName' => '', + 'givenName' => '', + 'surname' => '', + 'userPrincipalName' => '', + 'mail' => '', + 'jobTitle' => '', + 'officeLocation' => '', + 'mobilePhone' => '', + 'businessPhones' => [], + 'department' => '', + 'companyName' => '', + 'country' => '', + 'city' => '', + 'state' => '', + 'streetAddress' => '', + 'postalCode' => '', + 'preferredLanguage' => '', + 'accountEnabled' => false, + 'createdDateTime' => '', + 'lastPasswordChangeDateTime' => '', + 'userType' => '', + 'userRoles' => [], + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Send email via Office 365. + * + * @param string $to Recipient email address + * @param string $subject Email subject + * @param string $body Email body + * @param string $bodyType Body type (text, html) + * @param string $from Sender email address + * @param array $cc CC recipients + * @param array $bcc BCC recipients + * @param array $attachments Email attachments + * + * @return array{ + * success: bool, + * messageId: string, + * sentDateTime: string, + * message: string, + * error: string, + * } + */ + public function sendEmail( + string $to, + string $subject, + string $body, + string $bodyType = 'html', + string $from = '', + array $cc = [], + array $bcc = [], + array $attachments = [], + ): array { + try { + $requestData = [ + 'message' => [ + 'subject' => $subject, + 'body' => [ + 'contentType' => $bodyType, + 'content' => $body, + ], + 'toRecipients' => [ + [ + 'emailAddress' => [ + 'address' => $to, + ], + ], + ], + ], + 'saveToSentItems' => true, + ]; + + if ($from) { + $requestData['message']['from'] = [ + 'emailAddress' => [ + 'address' => $from, + ], + ]; + } + + if (!empty($cc)) { + $requestData['message']['ccRecipients'] = array_map(fn ($email) => [ + 'emailAddress' => [ + 'address' => $email, + ], + ], $cc); + } + + if (!empty($bcc)) { + $requestData['message']['bccRecipients'] = array_map(fn ($email) => [ + 'emailAddress' => [ + 'address' => $email, + ], + ], $bcc); + } + + if (!empty($attachments)) { + $requestData['message']['attachments'] = $attachments; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/me/sendMail", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + return [ + 'success' => true, + 'messageId' => $response->getHeaders()['x-ms-request-id'][0] ?? '', + 'sentDateTime' => date('c'), + 'message' => 'Email sent successfully', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'messageId' => '', + 'sentDateTime' => '', + 'message' => 'Failed to send email', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get emails from Office 365. + * + * @param string $folder Mail folder (inbox, sentitems, deleteditems, drafts) + * @param int $limit Number of emails + * @param int $offset Offset for pagination + * @param string $filter OData filter + * @param string $orderBy Order by field + * + * @return array{ + * success: bool, + * emails: array, + * ccRecipients: array, + * bccRecipients: array, + * receivedDateTime: string, + * sentDateTime: string, + * isRead: bool, + * importance: string, + * hasAttachments: bool, + * attachments: array, + * }>, + * total: int, + * limit: int, + * offset: int, + * error: string, + * } + */ + public function getEmails( + string $folder = 'inbox', + int $limit = 20, + int $offset = 0, + string $filter = '', + string $orderBy = 'receivedDateTime desc', + ): array { + try { + $params = [ + '$top' => max(1, min($limit, 999)), + '$skip' => max(0, $offset), + '$orderby' => $orderBy, + '$select' => 'id,subject,bodyPreview,body,from,toRecipients,ccRecipients,bccRecipients,receivedDateTime,sentDateTime,isRead,importance,hasAttachments,attachments', + ]; + + if ($filter) { + $params['$filter'] = $filter; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/me/mailFolders/{$folder}/messages", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'emails' => array_map(fn ($email) => [ + 'id' => $email['id'] ?? '', + 'subject' => $email['subject'] ?? '', + 'bodyPreview' => $email['bodyPreview'] ?? '', + 'body' => [ + 'contentType' => $email['body']['contentType'] ?? '', + 'content' => $email['body']['content'] ?? '', + ], + 'from' => [ + 'emailAddress' => [ + 'name' => $email['from']['emailAddress']['name'] ?? '', + 'address' => $email['from']['emailAddress']['address'] ?? '', + ], + ], + 'toRecipients' => array_map(fn ($recipient) => [ + 'emailAddress' => [ + 'name' => $recipient['emailAddress']['name'] ?? '', + 'address' => $recipient['emailAddress']['address'] ?? '', + ], + ], $email['toRecipients'] ?? []), + 'ccRecipients' => array_map(fn ($recipient) => [ + 'emailAddress' => [ + 'name' => $recipient['emailAddress']['name'] ?? '', + 'address' => $recipient['emailAddress']['address'] ?? '', + ], + ], $email['ccRecipients'] ?? []), + 'bccRecipients' => array_map(fn ($recipient) => [ + 'emailAddress' => [ + 'name' => $recipient['emailAddress']['name'] ?? '', + 'address' => $recipient['emailAddress']['address'] ?? '', + ], + ], $email['bccRecipients'] ?? []), + 'receivedDateTime' => $email['receivedDateTime'] ?? '', + 'sentDateTime' => $email['sentDateTime'] ?? '', + 'isRead' => $email['isRead'] ?? false, + 'importance' => $email['importance'] ?? 'normal', + 'hasAttachments' => $email['hasAttachments'] ?? false, + 'attachments' => array_map(fn ($attachment) => [ + 'id' => $attachment['id'] ?? '', + 'name' => $attachment['name'] ?? '', + 'contentType' => $attachment['contentType'] ?? '', + 'size' => $attachment['size'] ?? 0, + ], $email['attachments'] ?? []), + ], $data['value'] ?? []), + 'total' => \count($data['value'] ?? []), + 'limit' => $limit, + 'offset' => $offset, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'emails' => [], + 'total' => 0, + 'limit' => $limit, + 'offset' => $offset, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get calendar events. + * + * @param string $startTime Start time (ISO 8601 format) + * @param string $endTime End time (ISO 8601 format) + * @param int $limit Number of events + * @param string $calendarId Calendar ID (empty for default) + * + * @return array{ + * success: bool, + * events: array, + * }, + * attendees: array, + * organizer: array{ + * emailAddress: array{ + * name: string, + * address: string, + * }, + * }, + * isAllDay: bool, + * isCancelled: bool, + * isOnlineMeeting: bool, + * onlineMeetingProvider: string, + * onlineMeetingUrl: string, + * recurrence: array, + * reminderMinutesBeforeStart: int, + * sensitivity: string, + * showAs: string, + * importance: string, + * createdDateTime: string, + * lastModifiedDateTime: string, + * }>, + * total: int, + * error: string, + * } + */ + public function getCalendarEvents( + string $startTime = '', + string $endTime = '', + int $limit = 50, + string $calendarId = '', + ): array { + try { + $params = [ + '$top' => max(1, min($limit, 999)), + '$orderby' => 'start/dateTime asc', + ]; + + if ($startTime && $endTime) { + $params['$filter'] = "start/dateTime ge '{$startTime}' and end/dateTime le '{$endTime}'"; + } + + $endpoint = $calendarId ? "/me/calendars/{$calendarId}/events" : '/me/events'; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}{$endpoint}", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'events' => array_map(fn ($event) => [ + 'id' => $event['id'] ?? '', + 'subject' => $event['subject'] ?? '', + 'bodyPreview' => $event['bodyPreview'] ?? '', + 'start' => [ + 'dateTime' => $event['start']['dateTime'] ?? '', + 'timeZone' => $event['start']['timeZone'] ?? '', + ], + 'end' => [ + 'dateTime' => $event['end']['dateTime'] ?? '', + 'timeZone' => $event['end']['timeZone'] ?? '', + ], + 'location' => [ + 'displayName' => $event['location']['displayName'] ?? '', + 'address' => $event['location']['address'] ?? [], + ], + 'attendees' => array_map(fn ($attendee) => [ + 'emailAddress' => [ + 'name' => $attendee['emailAddress']['name'] ?? '', + 'address' => $attendee['emailAddress']['address'] ?? '', + ], + 'type' => $attendee['type'] ?? '', + 'status' => [ + 'response' => $attendee['status']['response'] ?? '', + 'time' => $attendee['status']['time'] ?? '', + ], + ], $event['attendees'] ?? []), + 'organizer' => [ + 'emailAddress' => [ + 'name' => $event['organizer']['emailAddress']['name'] ?? '', + 'address' => $event['organizer']['emailAddress']['address'] ?? '', + ], + ], + 'isAllDay' => $event['isAllDay'] ?? false, + 'isCancelled' => $event['isCancelled'] ?? false, + 'isOnlineMeeting' => $event['isOnlineMeeting'] ?? false, + 'onlineMeetingProvider' => $event['onlineMeetingProvider'] ?? '', + 'onlineMeetingUrl' => $event['onlineMeetingUrl'] ?? '', + 'recurrence' => $event['recurrence'] ?? [], + 'reminderMinutesBeforeStart' => $event['reminderMinutesBeforeStart'] ?? 15, + 'sensitivity' => $event['sensitivity'] ?? 'normal', + 'showAs' => $event['showAs'] ?? 'busy', + 'importance' => $event['importance'] ?? 'normal', + 'createdDateTime' => $event['createdDateTime'] ?? '', + 'lastModifiedDateTime' => $event['lastModifiedDateTime'] ?? '', + ], $data['value'] ?? []), + 'total' => \count($data['value'] ?? []), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'events' => [], + 'total' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create calendar event. + * + * @param string $subject Event subject + * @param string $startTime Start time (ISO 8601 format) + * @param string $endTime End time (ISO 8601 format) + * @param string $body Event body + * @param string $location Event location + * @param array $attendees Attendee email addresses + * @param string $calendarId Calendar ID (empty for default) + * @param bool $isAllDay Is all day event + * @param string $timeZone Time zone + * + * @return array{ + * success: bool, + * event: array{ + * id: string, + * subject: string, + * start: array{ + * dateTime: string, + * timeZone: string, + * }, + * end: array{ + * dateTime: string, + * timeZone: string, + * }, + * location: array{ + * displayName: string, + * }, + * attendees: array, + * isAllDay: bool, + * createdDateTime: string, + * }, + * error: string, + * } + */ + public function createCalendarEvent( + string $subject, + string $startTime, + string $endTime, + string $body = '', + string $location = '', + array $attendees = [], + string $calendarId = '', + bool $isAllDay = false, + string $timeZone = 'UTC', + ): array { + try { + $requestData = [ + 'subject' => $subject, + 'start' => [ + 'dateTime' => $startTime, + 'timeZone' => $timeZone, + ], + 'end' => [ + 'dateTime' => $endTime, + 'timeZone' => $timeZone, + ], + 'isAllDay' => $isAllDay, + ]; + + if ($body) { + $requestData['body'] = [ + 'contentType' => 'html', + 'content' => $body, + ]; + } + + if ($location) { + $requestData['location'] = [ + 'displayName' => $location, + ]; + } + + if (!empty($attendees)) { + $requestData['attendees'] = array_map(fn ($email) => [ + 'emailAddress' => [ + 'address' => $email, + ], + 'type' => 'required', + ], $attendees); + } + + $endpoint = $calendarId ? "/me/calendars/{$calendarId}/events" : '/me/events'; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}{$endpoint}", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'event' => [ + 'id' => $data['id'] ?? '', + 'subject' => $data['subject'] ?? $subject, + 'start' => [ + 'dateTime' => $data['start']['dateTime'] ?? $startTime, + 'timeZone' => $data['start']['timeZone'] ?? $timeZone, + ], + 'end' => [ + 'dateTime' => $data['end']['dateTime'] ?? $endTime, + 'timeZone' => $data['end']['timeZone'] ?? $timeZone, + ], + 'location' => [ + 'displayName' => $data['location']['displayName'] ?? $location, + ], + 'attendees' => array_map(fn ($attendee) => [ + 'emailAddress' => [ + 'address' => $attendee['emailAddress']['address'] ?? '', + ], + ], $data['attendees'] ?? []), + 'isAllDay' => $data['isAllDay'] ?? $isAllDay, + 'createdDateTime' => $data['createdDateTime'] ?? date('c'), + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'event' => [ + 'id' => '', + 'subject' => $subject, + 'start' => ['dateTime' => $startTime, 'timeZone' => $timeZone], + 'end' => ['dateTime' => $endTime, 'timeZone' => $timeZone], + 'location' => ['displayName' => $location], + 'attendees' => [], + 'isAllDay' => $isAllDay, + 'createdDateTime' => '', + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get OneDrive files. + * + * @param string $folderPath Folder path (empty for root) + * @param int $limit Number of files + * @param int $offset Offset for pagination + * @param string $filter Filter criteria + * + * @return array{ + * success: bool, + * files: array, + * }, + * folder: array{ + * childCount: int, + * }, + * createdDateTime: string, + * lastModifiedDateTime: string, + * createdBy: array{ + * user: array{ + * displayName: string, + * email: string, + * }, + * }, + * lastModifiedBy: array{ + * user: array{ + * displayName: string, + * email: string, + * }, + * }, + * }>, + * total: int, + * limit: int, + * offset: int, + * error: string, + * } + */ + public function getDriveFiles( + string $folderPath = '', + int $limit = 50, + int $offset = 0, + string $filter = '', + ): array { + try { + $params = [ + '$top' => max(1, min($limit, 999)), + '$skip' => max(0, $offset), + ]; + + if ($filter) { + $params['$filter'] = $filter; + } + + $endpoint = $folderPath ? "/me/drive/root:/{$folderPath}:/children" : '/me/drive/root/children'; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}{$endpoint}", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'files' => array_map(fn ($file) => [ + 'id' => $file['id'] ?? '', + 'name' => $file['name'] ?? '', + 'size' => $file['size'] ?? 0, + 'webUrl' => $file['webUrl'] ?? '', + 'downloadUrl' => $file['@microsoft.graph.downloadUrl'] ?? '', + 'file' => [ + 'mimeType' => $file['file']['mimeType'] ?? '', + 'hashes' => $file['file']['hashes'] ?? [], + ], + 'folder' => [ + 'childCount' => $file['folder']['childCount'] ?? 0, + ], + 'createdDateTime' => $file['createdDateTime'] ?? '', + 'lastModifiedDateTime' => $file['lastModifiedDateTime'] ?? '', + 'createdBy' => [ + 'user' => [ + 'displayName' => $file['createdBy']['user']['displayName'] ?? '', + 'email' => $file['createdBy']['user']['email'] ?? '', + ], + ], + 'lastModifiedBy' => [ + 'user' => [ + 'displayName' => $file['lastModifiedBy']['user']['displayName'] ?? '', + 'email' => $file['lastModifiedBy']['user']['email'] ?? '', + ], + ], + ], $data['value'] ?? []), + 'total' => \count($data['value'] ?? []), + 'limit' => $limit, + 'offset' => $offset, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'files' => [], + 'total' => 0, + 'limit' => $limit, + 'offset' => $offset, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Upload file to OneDrive. + * + * @param string $filePath Local file path + * @param string $destination Destination path in OneDrive + * @param bool $overwrite Overwrite existing file + * + * @return array{ + * success: bool, + * file: array{ + * id: string, + * name: string, + * size: int, + * webUrl: string, + * downloadUrl: string, + * createdDateTime: string, + * lastModifiedDateTime: string, + * }, + * message: string, + * error: string, + * } + */ + public function uploadFile( + string $filePath, + string $destination, + bool $overwrite = false, + ): array { + try { + if (!file_exists($filePath)) { + throw new \InvalidArgumentException("File not found: {$filePath}."); + } + + $fileContent = file_get_contents($filePath); + $fileName = basename($filePath); + + $headers = [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => mime_content_type($filePath) ?: 'application/octet-stream', + ]; + + if ($overwrite) { + $headers['Content-Length'] = \strlen($fileContent); + } + + $response = $this->httpClient->request('PUT', "{$this->baseUrl}/me/drive/root:/{$destination}/{$fileName}:/content", [ + 'headers' => $headers, + 'body' => $fileContent, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'file' => [ + 'id' => $data['id'] ?? '', + 'name' => $data['name'] ?? $fileName, + 'size' => $data['size'] ?? \strlen($fileContent), + 'webUrl' => $data['webUrl'] ?? '', + 'downloadUrl' => $data['@microsoft.graph.downloadUrl'] ?? '', + 'createdDateTime' => $data['createdDateTime'] ?? date('c'), + 'lastModifiedDateTime' => $data['lastModifiedDateTime'] ?? date('c'), + ], + 'message' => 'File uploaded successfully', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'file' => [ + 'id' => '', + 'name' => basename($filePath), + 'size' => 0, + 'webUrl' => '', + 'downloadUrl' => '', + 'createdDateTime' => '', + 'lastModifiedDateTime' => '', + ], + 'message' => 'Failed to upload file', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Microsoft Teams. + * + * @param int $limit Number of teams + * @param int $offset Offset for pagination + * @param string $filter Filter criteria + * + * @return array{ + * success: bool, + * teams: array, + * guestSettings: array, + * messagingSettings: array, + * funSettings: array, + * discoverySettings: array, + * summary: array{ + * ownersCount: int, + * membersCount: int, + * guestsCount: int, + * }, + * }>, + * total: int, + * limit: int, + * offset: int, + * error: string, + * } + */ + public function getTeams( + int $limit = 20, + int $offset = 0, + string $filter = '', + ): array { + try { + $params = [ + '$top' => max(1, min($limit, 999)), + '$skip' => max(0, $offset), + ]; + + if ($filter) { + $params['$filter'] = $filter; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/me/joinedTeams", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'teams' => array_map(fn ($team) => [ + 'id' => $team['id'] ?? '', + 'displayName' => $team['displayName'] ?? '', + 'description' => $team['description'] ?? '', + 'visibility' => $team['visibility'] ?? '', + 'createdDateTime' => $team['createdDateTime'] ?? '', + 'webUrl' => $team['webUrl'] ?? '', + 'isArchived' => $team['isArchived'] ?? false, + 'memberSettings' => $team['memberSettings'] ?? [], + 'guestSettings' => $team['guestSettings'] ?? [], + 'messagingSettings' => $team['messagingSettings'] ?? [], + 'funSettings' => $team['funSettings'] ?? [], + 'discoverySettings' => $team['discoverySettings'] ?? [], + 'summary' => [ + 'ownersCount' => $team['summary']['ownersCount'] ?? 0, + 'membersCount' => $team['summary']['membersCount'] ?? 0, + 'guestsCount' => $team['summary']['guestsCount'] ?? 0, + ], + ], $data['value'] ?? []), + 'total' => \count($data['value'] ?? []), + 'limit' => $limit, + 'offset' => $offset, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'teams' => [], + 'total' => 0, + 'limit' => $limit, + 'offset' => $offset, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/OneDrive.php b/src/agent/src/Toolbox/Tool/OneDrive.php new file mode 100644 index 000000000..dd18930da --- /dev/null +++ b/src/agent/src/Toolbox/Tool/OneDrive.php @@ -0,0 +1,548 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('onedrive_list_files', 'Tool that lists files and folders in OneDrive')] +#[AsTool('onedrive_upload_file', 'Tool that uploads files to OneDrive', method: 'uploadFile')] +#[AsTool('onedrive_download_file', 'Tool that downloads files from OneDrive', method: 'downloadFile')] +#[AsTool('onedrive_create_folder', 'Tool that creates folders in OneDrive', method: 'createFolder')] +#[AsTool('onedrive_share_file', 'Tool that shares files on OneDrive', method: 'shareFile')] +#[AsTool('onedrive_search_files', 'Tool that searches for files in OneDrive', method: 'searchFiles')] +final readonly class OneDrive +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private string $apiVersion = 'v1.0', + private array $options = [], + ) { + } + + /** + * List files and folders in OneDrive. + * + * @param string $path Path to list (use 'root' for root folder) + * @param int $top Maximum number of items to return + * @param string $orderBy Order by field (name, lastModifiedDateTime, size, etc.) + * @param string $filter Filter expression (e.g., "file ne null") + * + * @return array + */ + public function __invoke( + string $path = 'root', + int $top = 100, + string $orderBy = 'name', + string $filter = '', + ): array { + try { + $params = [ + '$top' => min(max($top, 1), 1000), + '$orderby' => $orderBy, + '$expand' => 'children', + ]; + + if ($filter) { + $params['$filter'] = $filter; + } + + $endpoint = 'root' === $path + ? "https://graph.microsoft.com/{$this->apiVersion}/me/drive/root/children" + : "https://graph.microsoft.com/{$this->apiVersion}/me/drive/root:/{$path}:/children"; + + $response = $this->httpClient->request('GET', $endpoint, [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['value'])) { + return []; + } + + $files = []; + foreach ($data['value'] as $item) { + $files[] = [ + 'id' => $item['id'], + 'name' => $item['name'], + 'size' => $item['size'] ?? 0, + 'createdDateTime' => $item['createdDateTime'], + 'lastModifiedDateTime' => $item['lastModifiedDateTime'], + 'webUrl' => $item['webUrl'], + 'downloadUrl' => $item['@microsoft.graph.downloadUrl'] ?? '', + 'file' => $item['file'] ?? null, + 'folder' => $item['folder'] ?? null, + 'parentReference' => [ + 'driveId' => $item['parentReference']['driveId'], + 'driveType' => $item['parentReference']['driveType'], + 'id' => $item['parentReference']['id'], + 'path' => $item['parentReference']['path'], + ], + 'createdBy' => [ + 'user' => [ + 'displayName' => $item['createdBy']['user']['displayName'] ?? '', + 'id' => $item['createdBy']['user']['id'] ?? '', + ], + ], + 'lastModifiedBy' => [ + 'user' => [ + 'displayName' => $item['lastModifiedBy']['user']['displayName'] ?? '', + 'id' => $item['lastModifiedBy']['user']['id'] ?? '', + ], + ], + ]; + } + + return $files; + } catch (\Exception $e) { + return []; + } + } + + /** + * Upload a file to OneDrive. + * + * @param string $filePath Path to the file to upload + * @param string $onedrivePath Destination path in OneDrive + * @param bool $overwrite Whether to overwrite if file exists + * + * @return array{ + * id: string, + * name: string, + * size: int, + * createdDateTime: string, + * lastModifiedDateTime: string, + * webUrl: string, + * downloadUrl: string, + * file: array{hashes: array{sha1Hash: string, quickXorHash: string}}, + * }|string + */ + public function uploadFile( + string $filePath, + string $onedrivePath, + bool $overwrite = true, + ): array|string { + try { + if (!file_exists($filePath)) { + return 'Error: File does not exist'; + } + + $fileContent = file_get_contents($filePath); + $fileName = basename($filePath); + $fullPath = rtrim($onedrivePath, '/').'/'.$fileName; + + $response = $this->httpClient->request('PUT', "https://graph.microsoft.com/{$this->apiVersion}/me/drive/root:/{$fullPath}:/content", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/octet-stream', + ], + 'body' => $fileContent, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error uploading file: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'size' => $data['size'], + 'createdDateTime' => $data['createdDateTime'], + 'lastModifiedDateTime' => $data['lastModifiedDateTime'], + 'webUrl' => $data['webUrl'], + 'downloadUrl' => $data['@microsoft.graph.downloadUrl'] ?? '', + 'file' => [ + 'hashes' => [ + 'sha1Hash' => $data['file']['hashes']['sha1Hash'] ?? '', + 'quickXorHash' => $data['file']['hashes']['quickXorHash'] ?? '', + ], + ], + ]; + } catch (\Exception $e) { + return 'Error uploading file: '.$e->getMessage(); + } + } + + /** + * Download a file from OneDrive. + * + * @param string $fileId OneDrive file ID + * @param string $localPath Local path to save the file + * + * @return array{ + * file_id: string, + * file_name: string, + * file_size: int, + * saved_path: string, + * metadata: array{ + * id: string, + * name: string, + * size: int, + * createdDateTime: string, + * lastModifiedDateTime: string, + * }, + * }|string + */ + public function downloadFile(string $fileId, string $localPath): array|string + { + try { + // First, get file metadata + $metadataResponse = $this->httpClient->request('GET', "https://graph.microsoft.com/{$this->apiVersion}/me/drive/items/{$fileId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + ]); + + $metadata = $metadataResponse->toArray(); + + if (isset($metadata['error'])) { + return 'Error getting file metadata: '.($metadata['error']['message'] ?? 'Unknown error'); + } + + // Download the file + $downloadResponse = $this->httpClient->request('GET', "https://graph.microsoft.com/{$this->apiVersion}/me/drive/items/{$fileId}/content", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + ]); + + $fileContent = $downloadResponse->getContent(); + + // Save to local path + if (is_dir($localPath)) { + $localPath = rtrim($localPath, '/').'/'.$metadata['name']; + } + + file_put_contents($localPath, $fileContent); + + return [ + 'file_id' => $fileId, + 'file_name' => $metadata['name'], + 'file_size' => \strlen($fileContent), + 'saved_path' => $localPath, + 'metadata' => [ + 'id' => $metadata['id'], + 'name' => $metadata['name'], + 'size' => $metadata['size'], + 'createdDateTime' => $metadata['createdDateTime'], + 'lastModifiedDateTime' => $metadata['lastModifiedDateTime'], + ], + ]; + } catch (\Exception $e) { + return 'Error downloading file: '.$e->getMessage(); + } + } + + /** + * Create a folder in OneDrive. + * + * @param string $path Path where to create the folder + * @param string $folderName Name of the folder to create + * + * @return array{ + * id: string, + * name: string, + * createdDateTime: string, + * lastModifiedDateTime: string, + * webUrl: string, + * folder: array{childCount: int}, + * }|string + */ + public function createFolder(string $path, string $folderName): array|string + { + try { + $endpoint = 'root' === $path + ? "https://graph.microsoft.com/{$this->apiVersion}/me/drive/root/children" + : "https://graph.microsoft.com/{$this->apiVersion}/me/drive/root:/{$path}:/children"; + + $response = $this->httpClient->request('POST', $endpoint, [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'name' => $folderName, + 'folder' => new \stdClass(), + '@microsoft.graph.conflictBehavior' => 'rename', + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating folder: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'createdDateTime' => $data['createdDateTime'], + 'lastModifiedDateTime' => $data['lastModifiedDateTime'], + 'webUrl' => $data['webUrl'], + 'folder' => [ + 'childCount' => $data['folder']['childCount'] ?? 0, + ], + ]; + } catch (\Exception $e) { + return 'Error creating folder: '.$e->getMessage(); + } + } + + /** + * Share a file on OneDrive. + * + * @param string $fileId OneDrive file ID + * @param string $type Share type (view, edit) + * @param string $scope Share scope (anonymous, organization, users) + * @param string $password Optional password for the link + * @param string $expirationDateTime Expiration date (ISO 8601 format) + * + * @return array{ + * id: string, + * name: string, + * webUrl: string, + * type: string, + * scope: string, + * hasPassword: bool, + * expirationDateTime: string, + * link: array{ + * type: string, + * scope: string, + * webUrl: string, + * application: array{displayName: string, id: string}, + * }, + * }|string + */ + public function shareFile( + string $fileId, + string $type = 'view', + string $scope = 'anonymous', + string $password = '', + string $expirationDateTime = '', + ): array|string { + try { + $permission = [ + 'type' => $type, + 'scope' => $scope, + ]; + + if ($password) { + $permission['password'] = $password; + } + + if ($expirationDateTime) { + $permission['expirationDateTime'] = $expirationDateTime; + } + + $response = $this->httpClient->request('POST', "https://graph.microsoft.com/{$this->apiVersion}/me/drive/items/{$fileId}/createLink", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $permission, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error sharing file: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'webUrl' => $data['webUrl'], + 'type' => $data['type'], + 'scope' => $data['scope'], + 'hasPassword' => $data['hasPassword'] ?? false, + 'expirationDateTime' => $data['expirationDateTime'] ?? '', + 'link' => [ + 'type' => $data['link']['type'], + 'scope' => $data['link']['scope'], + 'webUrl' => $data['link']['webUrl'], + 'application' => [ + 'displayName' => $data['link']['application']['displayName'], + 'id' => $data['link']['application']['id'], + ], + ], + ]; + } catch (\Exception $e) { + return 'Error sharing file: '.$e->getMessage(); + } + } + + /** + * Search for files in OneDrive. + * + * @param string $query Search query + * @param int $top Maximum number of results + * @param string $orderBy Order by field + * + * @return array + */ + public function searchFiles( + #[With(maximum: 500)] + string $query, + int $top = 20, + string $orderBy = 'lastModifiedDateTime desc', + ): array { + try { + $response = $this->httpClient->request('GET', "https://graph.microsoft.com/{$this->apiVersion}/me/drive/root/search(q='{$query}')", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => [ + '$top' => min(max($top, 1), 1000), + '$orderby' => $orderBy, + ], + ]); + + $data = $response->toArray(); + + if (!isset($data['value'])) { + return []; + } + + $results = []; + foreach ($data['value'] as $item) { + $results[] = [ + 'id' => $item['id'], + 'name' => $item['name'], + 'size' => $item['size'] ?? 0, + 'createdDateTime' => $item['createdDateTime'], + 'lastModifiedDateTime' => $item['lastModifiedDateTime'], + 'webUrl' => $item['webUrl'], + 'downloadUrl' => $item['@microsoft.graph.downloadUrl'] ?? '', + 'file' => $item['file'] ?? null, + 'folder' => $item['folder'] ?? null, + 'parentReference' => [ + 'driveId' => $item['parentReference']['driveId'], + 'driveType' => $item['parentReference']['driveType'], + 'id' => $item['parentReference']['id'], + 'path' => $item['parentReference']['path'], + ], + ]; + } + + return $results; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get file metadata from OneDrive. + * + * @param string $fileId OneDrive file ID + * + * @return array{ + * id: string, + * name: string, + * size: int, + * createdDateTime: string, + * lastModifiedDateTime: string, + * webUrl: string, + * downloadUrl: string, + * file: array{hashes: array{sha1Hash: string, quickXorHash: string}}|null, + * folder: array{childCount: int}|null, + * parentReference: array{driveId: string, driveType: string, id: string, path: string}, + * createdBy: array{user: array{displayName: string, id: string}}, + * lastModifiedBy: array{user: array{displayName: string, id: string}}, + * }|string + */ + public function getFileMetadata(string $fileId): array|string + { + try { + $response = $this->httpClient->request('GET', "https://graph.microsoft.com/{$this->apiVersion}/me/drive/items/{$fileId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting file metadata: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'size' => $data['size'] ?? 0, + 'createdDateTime' => $data['createdDateTime'], + 'lastModifiedDateTime' => $data['lastModifiedDateTime'], + 'webUrl' => $data['webUrl'], + 'downloadUrl' => $data['@microsoft.graph.downloadUrl'] ?? '', + 'file' => $data['file'] ?? null, + 'folder' => $data['folder'] ?? null, + 'parentReference' => [ + 'driveId' => $data['parentReference']['driveId'], + 'driveType' => $data['parentReference']['driveType'], + 'id' => $data['parentReference']['id'], + 'path' => $data['parentReference']['path'], + ], + 'createdBy' => [ + 'user' => [ + 'displayName' => $data['createdBy']['user']['displayName'] ?? '', + 'id' => $data['createdBy']['user']['id'] ?? '', + ], + ], + 'lastModifiedBy' => [ + 'user' => [ + 'displayName' => $data['lastModifiedBy']['user']['displayName'] ?? '', + 'id' => $data['lastModifiedBy']['user']['id'] ?? '', + ], + ], + ]; + } catch (\Exception $e) { + return 'Error getting file metadata: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/OpenAiDalle.php b/src/agent/src/Toolbox/Tool/OpenAiDalle.php new file mode 100644 index 000000000..b46b99fa4 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/OpenAiDalle.php @@ -0,0 +1,350 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('openai_dalle_generate_image', 'Tool that generates images using OpenAI DALL-E')] +#[AsTool('openai_dalle_create_variation', 'Tool that creates image variations using DALL-E', method: 'createVariation')] +#[AsTool('openai_dalle_edit_image', 'Tool that edits images using DALL-E', method: 'editImage')] +#[AsTool('openai_dalle_download_image', 'Tool that downloads generated images', method: 'downloadImage')] +final readonly class OpenAiDalle +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.openai.com/v1', + private array $options = [], + ) { + } + + /** + * Generate image using OpenAI DALL-E. + * + * @param string $prompt Image generation prompt + * @param int $n Number of images to generate (1-10) + * @param string $size Image size (256x256, 512x512, 1024x1024, 1792x1024, 1024x1792) + * @param string $quality Image quality (standard, hd) + * @param string $style Image style (vivid, natural) + * @param string $responseFormat Response format (url, b64_json) + * + * @return array{ + * success: bool, + * images: array, + * created: int, + * usage: array{ + * promptTokens: int, + * completionTokens: int, + * totalTokens: int, + * }, + * error: string, + * } + */ + public function __invoke( + string $prompt, + int $n = 1, + string $size = '1024x1024', + string $quality = 'standard', + string $style = 'vivid', + string $responseFormat = 'url', + ): array { + try { + $requestData = [ + 'model' => 'dall-e-3', + 'prompt' => $prompt, + 'n' => max(1, min($n, 10)), + 'size' => $size, + 'quality' => $quality, + 'style' => $style, + 'response_format' => $responseFormat, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/images/generations", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'images' => array_map(fn ($image) => [ + 'url' => $image['url'] ?? '', + 'revisedPrompt' => $image['revised_prompt'] ?? $prompt, + 'b64Json' => $image['b64_json'] ?? '', + ], $data['data'] ?? []), + 'created' => $data['created'] ?? time(), + 'usage' => [ + 'promptTokens' => $data['usage']['prompt_tokens'] ?? 0, + 'completionTokens' => $data['usage']['completion_tokens'] ?? 0, + 'totalTokens' => $data['usage']['total_tokens'] ?? 0, + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'images' => [], + 'created' => 0, + 'usage' => ['promptTokens' => 0, 'completionTokens' => 0, 'totalTokens' => 0], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create image variation using DALL-E. + * + * @param string $imagePath Path to the source image + * @param int $n Number of variations to generate (1-10) + * @param string $size Image size (256x256, 512x512, 1024x1024, 1792x1024, 1024x1792) + * @param string $responseFormat Response format (url, b64_json) + * + * @return array{ + * success: bool, + * images: array, + * created: int, + * usage: array{ + * promptTokens: int, + * completionTokens: int, + * totalTokens: int, + * }, + * error: string, + * } + */ + public function createVariation( + string $imagePath, + int $n = 1, + string $size = '1024x1024', + string $responseFormat = 'url', + ): array { + try { + if (!file_exists($imagePath)) { + throw new \InvalidArgumentException("Image file not found: {$imagePath}."); + } + + $imageData = base64_encode(file_get_contents($imagePath)); + + $requestData = [ + 'model' => 'dall-e-2', + 'image' => "data:image/jpeg;base64,{$imageData}", + 'n' => max(1, min($n, 10)), + 'size' => $size, + 'response_format' => $responseFormat, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/images/variations", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'images' => array_map(fn ($image) => [ + 'url' => $image['url'] ?? '', + 'revisedPrompt' => $image['revised_prompt'] ?? '', + 'b64Json' => $image['b64_json'] ?? '', + ], $data['data'] ?? []), + 'created' => $data['created'] ?? time(), + 'usage' => [ + 'promptTokens' => $data['usage']['prompt_tokens'] ?? 0, + 'completionTokens' => $data['usage']['completion_tokens'] ?? 0, + 'totalTokens' => $data['usage']['total_tokens'] ?? 0, + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'images' => [], + 'created' => 0, + 'usage' => ['promptTokens' => 0, 'completionTokens' => 0, 'totalTokens' => 0], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Edit image using DALL-E. + * + * @param string $imagePath Path to the source image + * @param string $maskPath Path to the mask image (optional) + * @param string $prompt Edit instruction prompt + * @param int $n Number of edited images to generate (1-10) + * @param string $size Image size (256x256, 512x512, 1024x1024, 1792x1024, 1024x1792) + * @param string $responseFormat Response format (url, b64_json) + * + * @return array{ + * success: bool, + * images: array, + * created: int, + * usage: array{ + * promptTokens: int, + * completionTokens: int, + * totalTokens: int, + * }, + * error: string, + * } + */ + public function editImage( + string $imagePath, + string $maskPath = '', + string $prompt = '', + int $n = 1, + string $size = '1024x1024', + string $responseFormat = 'url', + ): array { + try { + if (!file_exists($imagePath)) { + throw new \InvalidArgumentException("Image file not found: {$imagePath}."); + } + + $imageData = base64_encode(file_get_contents($imagePath)); + + $requestData = [ + 'model' => 'dall-e-2', + 'image' => "data:image/jpeg;base64,{$imageData}", + 'prompt' => $prompt, + 'n' => max(1, min($n, 10)), + 'size' => $size, + 'response_format' => $responseFormat, + ]; + + if ($maskPath && file_exists($maskPath)) { + $maskData = base64_encode(file_get_contents($maskPath)); + $requestData['mask'] = "data:image/jpeg;base64,{$maskData}"; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/images/edits", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'images' => array_map(fn ($image) => [ + 'url' => $image['url'] ?? '', + 'revisedPrompt' => $image['revised_prompt'] ?? $prompt, + 'b64Json' => $image['b64_json'] ?? '', + ], $data['data'] ?? []), + 'created' => $data['created'] ?? time(), + 'usage' => [ + 'promptTokens' => $data['usage']['prompt_tokens'] ?? 0, + 'completionTokens' => $data['usage']['completion_tokens'] ?? 0, + 'totalTokens' => $data['usage']['total_tokens'] ?? 0, + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'images' => [], + 'created' => 0, + 'usage' => ['promptTokens' => 0, 'completionTokens' => 0, 'totalTokens' => 0], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Download generated image. + * + * @param string $imageUrl URL of the generated image + * @param string $outputPath Path to save the downloaded image + * + * @return array{ + * success: bool, + * filePath: string, + * fileSize: int, + * mimeType: string, + * error: string, + * } + */ + public function downloadImage( + string $imageUrl, + string $outputPath, + ): array { + try { + $response = $this->httpClient->request('GET', $imageUrl, [ + 'headers' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + ], + ]); + + $imageData = $response->getContent(); + $headers = $response->getHeaders(false); + + // Create directory if it doesn't exist + $outputDir = \dirname($outputPath); + if (!is_dir($outputDir)) { + mkdir($outputDir, 0755, true); + } + + // Save image to file + file_put_contents($outputPath, $imageData); + + // Determine MIME type + $mimeType = 'application/octet-stream'; + if (isset($headers['content-type'][0])) { + $mimeType = $headers['content-type'][0]; + } + + return [ + 'success' => true, + 'filePath' => $outputPath, + 'fileSize' => \strlen($imageData), + 'mimeType' => $mimeType, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'filePath' => '', + 'fileSize' => 0, + 'mimeType' => '', + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/OpenApiClient.php b/src/agent/src/Toolbox/Tool/OpenApiClient.php new file mode 100644 index 000000000..886c3bb9f --- /dev/null +++ b/src/agent/src/Toolbox/Tool/OpenApiClient.php @@ -0,0 +1,641 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('openapi_get_spec', 'Tool that gets OpenAPI specification')] +#[AsTool('openapi_execute_operation', 'Tool that executes OpenAPI operations', method: 'executeOperation')] +#[AsTool('openapi_list_operations', 'Tool that lists OpenAPI operations', method: 'listOperations')] +#[AsTool('openapi_validate_request', 'Tool that validates OpenAPI requests', method: 'validateRequest')] +#[AsTool('openapi_generate_client', 'Tool that generates OpenAPI client code', method: 'generateClient')] +#[AsTool('openapi_test_endpoint', 'Tool that tests OpenAPI endpoints', method: 'testEndpoint')] +final readonly class OpenApiClient +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $specUrl = '', + private string $baseUrl = '', + private string $apiKey = '', + private array $headers = [], + private array $options = [], + ) { + } + + /** + * Get OpenAPI specification. + * + * @param string $specUrl OpenAPI specification URL + * + * @return array{ + * success: bool, + * spec: array, + * info: array{ + * title: string, + * version: string, + * description: string, + * contact: array, + * license: array, + * }, + * servers: array, + * }>, + * paths: array, + * components: array, + * error: string, + * } + */ + public function __invoke(string $specUrl = ''): array + { + try { + $url = $specUrl ?: $this->specUrl; + if (!$url) { + throw new \InvalidArgumentException('OpenAPI specification URL is required.'); + } + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => array_merge($this->options, $this->headers), + ]); + + $spec = $response->toArray(); + + return [ + 'success' => true, + 'spec' => $spec, + 'info' => [ + 'title' => $spec['info']['title'] ?? '', + 'version' => $spec['info']['version'] ?? '', + 'description' => $spec['info']['description'] ?? '', + 'contact' => $spec['info']['contact'] ?? [], + 'license' => $spec['info']['license'] ?? [], + ], + 'servers' => array_map(fn ($server) => [ + 'url' => $server['url'], + 'description' => $server['description'] ?? '', + 'variables' => $server['variables'] ?? [], + ], $spec['servers'] ?? []), + 'paths' => $spec['paths'] ?? [], + 'components' => $spec['components'] ?? [], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'spec' => [], + 'info' => ['title' => '', 'version' => '', 'description' => '', 'contact' => [], 'license' => []], + 'servers' => [], + 'paths' => [], + 'components' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Execute OpenAPI operation. + * + * @param string $path API path + * @param string $method HTTP method (GET, POST, PUT, DELETE, etc.) + * @param array $parameters Path and query parameters + * @param array $requestBody Request body for POST/PUT + * @param array $headers Additional headers + * + * @return array{ + * success: bool, + * statusCode: int, + * headers: array, + * body: mixed, + * responseTime: float, + * error: string, + * } + */ + public function executeOperation( + string $path, + string $method = 'GET', + array $parameters = [], + array $requestBody = [], + array $headers = [], + ): array { + try { + $startTime = microtime(true); + + // Build full URL + $url = $this->buildUrl($path, $parameters); + + // Prepare request options + $requestOptions = [ + 'headers' => array_merge($this->headers, $headers), + ]; + + // Add request body for POST/PUT methods + if (\in_array(strtoupper($method), ['POST', 'PUT', 'PATCH']) && !empty($requestBody)) { + $requestOptions['json'] = $requestBody; + } + + // Add API key if available + if ($this->apiKey) { + $requestOptions['headers']['Authorization'] = "Bearer {$this->apiKey}"; + } + + $response = $this->httpClient->request(strtoupper($method), $url, $requestOptions); + + $responseTime = microtime(true) - $startTime; + $statusCode = $response->getStatusCode(); + $responseHeaders = $response->getHeaders(false); + + try { + $body = $response->toArray(); + } catch (\Exception $e) { + $body = $response->getContent(); + } + + return [ + 'success' => $statusCode >= 200 && $statusCode < 300, + 'statusCode' => $statusCode, + 'headers' => array_map(fn ($values) => implode(', ', $values), $responseHeaders), + 'body' => $body, + 'responseTime' => $responseTime, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'statusCode' => 0, + 'headers' => [], + 'body' => null, + 'responseTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * List OpenAPI operations. + * + * @param string $specUrl OpenAPI specification URL + * + * @return array{ + * success: bool, + * operations: array, + * }>, + * requestBody: array, + * responses: array, + * }>, + * }>, + * error: string, + * } + */ + public function listOperations(string $specUrl = ''): array + { + try { + $spec = $this->__invoke($specUrl); + if (!$spec['success']) { + return [ + 'success' => false, + 'operations' => [], + 'error' => $spec['error'], + ]; + } + + $operations = []; + foreach ($spec['paths'] as $path => $pathItem) { + foreach ($pathItem as $method => $operation) { + if (\in_array(strtoupper($method), ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'])) { + $operations[] = [ + 'path' => $path, + 'method' => strtoupper($method), + 'operationId' => $operation['operationId'] ?? '', + 'summary' => $operation['summary'] ?? '', + 'description' => $operation['description'] ?? '', + 'parameters' => array_map(fn ($param) => [ + 'name' => $param['name'], + 'in' => $param['in'], + 'required' => $param['required'] ?? false, + 'schema' => $param['schema'] ?? [], + ], $operation['parameters'] ?? []), + 'requestBody' => $operation['requestBody'] ?? [], + 'responses' => array_map(fn ($response) => [ + 'description' => $response['description'] ?? '', + 'content' => $response['content'] ?? [], + ], $operation['responses'] ?? []), + ]; + } + } + } + + return [ + 'success' => true, + 'operations' => $operations, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'operations' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Validate OpenAPI request. + * + * @param string $path API path + * @param string $method HTTP method + * @param array $parameters Request parameters + * @param array $requestBody Request body + * @param string $specUrl OpenAPI specification URL + * + * @return array{ + * success: bool, + * valid: bool, + * errors: array, + * warnings: array, + * validatedParameters: array, + * validatedBody: array, + * } + */ + public function validateRequest( + string $path, + string $method, + array $parameters, + array $requestBody = [], + string $specUrl = '', + ): array { + try { + $spec = $this->__invoke($specUrl); + if (!$spec['success']) { + return [ + 'success' => false, + 'valid' => false, + 'errors' => [$spec['error']], + 'warnings' => [], + 'validatedParameters' => [], + 'validatedBody' => [], + ]; + } + + $errors = []; + $warnings = []; + $validatedParameters = []; + $validatedBody = []; + + // Find the operation in the spec + $operation = $this->findOperation($spec['paths'], $path, $method); + if (!$operation) { + $errors[] = "Operation {$method} {$path} not found in specification"; + + return [ + 'success' => true, + 'valid' => false, + 'errors' => $errors, + 'warnings' => $warnings, + 'validatedParameters' => $validatedParameters, + 'validatedBody' => $validatedBody, + ]; + } + + // Validate parameters + foreach ($operation['parameters'] ?? [] as $paramSpec) { + $paramName = $paramSpec['name']; + $paramIn = $paramSpec['in']; + $required = $paramSpec['required'] ?? false; + + if ($required && !isset($parameters[$paramName])) { + $errors[] = "Required parameter '{$paramName}' is missing"; + } elseif (isset($parameters[$paramName])) { + $validatedParameters[$paramName] = $parameters[$paramName]; + } + } + + // Validate request body + if (\in_array(strtoupper($method), ['POST', 'PUT', 'PATCH']) && isset($operation['requestBody'])) { + $validatedBody = $requestBody; + } + + return [ + 'success' => true, + 'valid' => empty($errors), + 'errors' => $errors, + 'warnings' => $warnings, + 'validatedParameters' => $validatedParameters, + 'validatedBody' => $validatedBody, + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'valid' => false, + 'errors' => [$e->getMessage()], + 'warnings' => [], + 'validatedParameters' => [], + 'validatedBody' => [], + ]; + } + } + + /** + * Generate OpenAPI client code. + * + * @param string $specUrl OpenAPI specification URL + * @param string $language Programming language (php, javascript, python, java) + * @param string $outputDir Output directory + * + * @return array{ + * success: bool, + * generatedFiles: array, + * message: string, + * error: string, + * } + */ + public function generateClient( + string $specUrl, + string $language = 'php', + string $outputDir = './generated', + ): array { + try { + $spec = $this->__invoke($specUrl); + if (!$spec['success']) { + return [ + 'success' => false, + 'generatedFiles' => [], + 'message' => 'Failed to load OpenAPI specification', + 'error' => $spec['error'], + ]; + } + + $generatedFiles = []; + + // This is a simplified client generation + // In reality, you would use tools like OpenAPI Generator + switch (strtolower($language)) { + case 'php': + $generatedFiles[] = $this->generatePhpClient($spec['spec'], $outputDir); + break; + case 'javascript': + $generatedFiles[] = $this->generateJavaScriptClient($spec['spec'], $outputDir); + break; + case 'python': + $generatedFiles[] = $this->generatePythonClient($spec['spec'], $outputDir); + break; + case 'java': + $generatedFiles[] = $this->generateJavaClient($spec['spec'], $outputDir); + break; + default: + throw new \InvalidArgumentException("Unsupported language: {$language}."); + } + + return [ + 'success' => true, + 'generatedFiles' => $generatedFiles, + 'message' => "Client code generated successfully in {$language}", + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'generatedFiles' => [], + 'message' => 'Failed to generate client code', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Test OpenAPI endpoint. + * + * @param string $path API path + * @param string $method HTTP method + * @param array $parameters Request parameters + * @param array $requestBody Request body + * @param int $timeout Request timeout in seconds + * + * @return array{ + * success: bool, + * testResults: array{ + * statusCode: int, + * responseTime: float, + * responseSize: int, + * headers: array, + * body: mixed, + * errors: array, + * }, + * performance: array{ + * averageResponseTime: float, + * minResponseTime: float, + * maxResponseTime: float, + * totalRequests: int, + * successfulRequests: int, + * failedRequests: int, + * }, + * error: string, + * } + */ + public function testEndpoint( + string $path, + string $method = 'GET', + array $parameters = [], + array $requestBody = [], + int $timeout = 30, + ): array { + try { + $testResults = []; + $responseTimes = []; + $successCount = 0; + $failCount = 0; + + // Run multiple test requests + for ($i = 0; $i < 5; ++$i) { + $result = $this->executeOperation($path, $method, $parameters, $requestBody); + + $testResults[] = [ + 'statusCode' => $result['statusCode'], + 'responseTime' => $result['responseTime'], + 'responseSize' => \is_string($result['body']) ? \strlen($result['body']) : 0, + 'headers' => $result['headers'], + 'body' => $result['body'], + 'errors' => $result['success'] ? [] : [$result['error']], + ]; + + $responseTimes[] = $result['responseTime']; + if ($result['success']) { + ++$successCount; + } else { + ++$failCount; + } + } + + $performance = [ + 'averageResponseTime' => array_sum($responseTimes) / \count($responseTimes), + 'minResponseTime' => min($responseTimes), + 'maxResponseTime' => max($responseTimes), + 'totalRequests' => 5, + 'successfulRequests' => $successCount, + 'failedRequests' => $failCount, + ]; + + return [ + 'success' => $successCount > 0, + 'testResults' => $testResults, + 'performance' => $performance, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'testResults' => [], + 'performance' => [ + 'averageResponseTime' => 0.0, + 'minResponseTime' => 0.0, + 'maxResponseTime' => 0.0, + 'totalRequests' => 0, + 'successfulRequests' => 0, + 'failedRequests' => 0, + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Build full URL from path and parameters. + */ + private function buildUrl(string $path, array $parameters): string + { + $baseUrl = $this->baseUrl ?: 'http://localhost'; + $url = rtrim($baseUrl, '/').'/'.ltrim($path, '/'); + + // Replace path parameters + foreach ($parameters as $key => $value) { + $url = str_replace("{{$key}}", $value, $url); + } + + return $url; + } + + /** + * Find operation in OpenAPI paths. + */ + private function findOperation(array $paths, string $path, string $method): ?array + { + foreach ($paths as $pathPattern => $pathItem) { + if ($this->matchPath($pathPattern, $path)) { + return $pathItem[strtolower($method)] ?? null; + } + } + + return null; + } + + /** + * Match path pattern with actual path. + */ + private function matchPath(string $pattern, string $path): bool + { + // Simple pattern matching - in reality, you'd need more sophisticated matching + return $pattern === $path || fnmatch($pattern, $path); + } + + /** + * Generate PHP client. + */ + private function generatePhpClient(array $spec, string $outputDir): string + { + $className = $spec['info']['title'] ?? 'ApiClient'; + $filename = "{$outputDir}/{$className}.php"; + + // Create directory if it doesn't exist + if (!is_dir($outputDir)) { + mkdir($outputDir, 0755, true); + } + + $content = " + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('open_weather_map', 'Tool that queries the OpenWeatherMap API for weather information')] +#[AsTool('open_weather_forecast', 'Tool that gets weather forecast from OpenWeatherMap', method: 'getForecast')] +final readonly class OpenWeatherMap +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $units = 'metric', + private array $options = [], + ) { + } + + /** + * Get current weather information for a location. + * + * @param string $location location string (e.g. London,GB or London,US or just London) + * + * @return array{ + * location: string, + * temperature: float, + * description: string, + * humidity: int, + * pressure: int, + * wind_speed: float, + * wind_direction: int, + * visibility: int, + * clouds: int, + * sunrise: string, + * sunset: string, + * }|string + */ + public function __invoke(string $location): array|string + { + try { + // First, get coordinates for the location + $coordinates = $this->getCoordinates($location); + + if (!$coordinates) { + return "Location '{$location}' not found."; + } + + $response = $this->httpClient->request('GET', 'https://api.openweathermap.org/data/2.5/weather', [ + 'query' => array_merge($this->options, [ + 'lat' => $coordinates['lat'], + 'lon' => $coordinates['lon'], + 'appid' => $this->apiKey, + 'units' => $this->units, + ]), + ]); + + $data = $response->toArray(); + + return [ + 'location' => $data['name'].', '.$data['sys']['country'], + 'temperature' => $data['main']['temp'], + 'description' => $data['weather'][0]['description'], + 'humidity' => $data['main']['humidity'], + 'pressure' => $data['main']['pressure'], + 'wind_speed' => $data['wind']['speed'] ?? 0, + 'wind_direction' => $data['wind']['deg'] ?? 0, + 'visibility' => $data['visibility'] ?? 0, + 'clouds' => $data['clouds']['all'], + 'sunrise' => date('Y-m-d H:i:s', $data['sys']['sunrise']), + 'sunset' => date('Y-m-d H:i:s', $data['sys']['sunset']), + ]; + } catch (\Exception $e) { + return 'Error fetching weather data: '.$e->getMessage(); + } + } + + /** + * Get weather forecast for a location. + * + * @param string $location location string (e.g. London,GB or London,US or just London) + * @param int $days Number of forecast days (1-5) + * + * @return array|string + */ + public function getForecast(string $location, int $days = 5): array|string + { + try { + // First, get coordinates for the location + $coordinates = $this->getCoordinates($location); + + if (!$coordinates) { + return "Location '{$location}' not found."; + } + + $response = $this->httpClient->request('GET', 'https://api.openweathermap.org/data/2.5/forecast', [ + 'query' => array_merge($this->options, [ + 'lat' => $coordinates['lat'], + 'lon' => $coordinates['lon'], + 'appid' => $this->apiKey, + 'units' => $this->units, + 'cnt' => $days * 8, // 8 forecasts per day (every 3 hours) + ]), + ]); + + $data = $response->toArray(); + $forecasts = []; + + // Group forecasts by date + $dailyForecasts = []; + foreach ($data['list'] as $forecast) { + $date = date('Y-m-d', $forecast['dt']); + + if (!isset($dailyForecasts[$date])) { + $dailyForecasts[$date] = [ + 'date' => $date, + 'temperatures' => [], + 'descriptions' => [], + 'humidities' => [], + 'wind_speeds' => [], + ]; + } + + $dailyForecasts[$date]['temperatures'][] = $forecast['main']['temp']; + $dailyForecasts[$date]['descriptions'][] = $forecast['weather'][0]['description']; + $dailyForecasts[$date]['humidities'][] = $forecast['main']['humidity']; + $dailyForecasts[$date]['wind_speeds'][] = $forecast['wind']['speed'] ?? 0; + } + + // Calculate daily averages and min/max + foreach ($dailyForecasts as $date => $dayData) { + $forecasts[] = [ + 'date' => $date, + 'temperature_min' => min($dayData['temperatures']), + 'temperature_max' => max($dayData['temperatures']), + 'description' => $this->getMostCommonDescription($dayData['descriptions']), + 'humidity' => (int) round(array_sum($dayData['humidities']) / \count($dayData['humidities'])), + 'wind_speed' => round(array_sum($dayData['wind_speeds']) / \count($dayData['wind_speeds']), 2), + ]; + } + + return \array_slice($forecasts, 0, $days); + } catch (\Exception $e) { + return 'Error fetching forecast data: '.$e->getMessage(); + } + } + + /** + * Get coordinates for a location. + * + * @return array{lat: float, lon: float}|null + */ + private function getCoordinates(string $location): ?array + { + try { + $response = $this->httpClient->request('GET', 'https://api.openweathermap.org/geo/1.0/direct', [ + 'query' => [ + 'q' => $location, + 'limit' => 1, + 'appid' => $this->apiKey, + ], + ]); + + $data = $response->toArray(); + + if (empty($data)) { + return null; + } + + return [ + 'lat' => $data[0]['lat'], + 'lon' => $data[0]['lon'], + ]; + } catch (\Exception $e) { + return null; + } + } + + /** + * Get the most common weather description from a list. + * + * @param array $descriptions + */ + private function getMostCommonDescription(array $descriptions): string + { + $counts = array_count_values($descriptions); + arsort($counts); + + return array_key_first($counts) ?? 'Unknown'; + } +} diff --git a/src/agent/src/Toolbox/Tool/Packer.php b/src/agent/src/Toolbox/Tool/Packer.php new file mode 100644 index 000000000..f137f0e75 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Packer.php @@ -0,0 +1,482 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('packer_validate', 'Tool that validates Packer configuration')] +#[AsTool('packer_build', 'Tool that builds Packer images', method: 'build')] +#[AsTool('packer_fmt', 'Tool that formats Packer configuration', method: 'fmt')] +#[AsTool('packer_fix', 'Tool that fixes Packer configuration', method: 'fix')] +#[AsTool('packer_inspect', 'Tool that inspects Packer configuration', method: 'inspect')] +#[AsTool('packer_version', 'Tool that shows Packer version', method: 'version')] +final readonly class Packer +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $workingDirectory = '.', + private array $options = [], + ) { + } + + /** + * Validate Packer configuration. + * + * @param string $configFile Configuration file path + * @param string $syntaxOnly Only check syntax + * @param string $varFile Variable file path + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * warnings: array, + * }|string + */ + public function __invoke( + string $configFile = '*.pkr.hcl', + string $syntaxOnly = 'false', + string $varFile = '', + ): array|string { + try { + $command = ['packer', 'validate']; + + if ('true' === $syntaxOnly) { + $command[] = '-syntax-only'; + } + + if ($varFile) { + $command[] = "-var-file={$varFile}"; + } + + $command[] = $configFile; + + $output = $this->executeCommand($command); + + // Parse warnings from output + $warnings = []; + $lines = explode("\n", $output); + foreach ($lines as $line) { + if (str_contains($line, 'Warning:')) { + $warnings[] = trim($line); + } + } + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + 'warnings' => $warnings, + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + 'warnings' => [], + ]; + } + } + + /** + * Build Packer images. + * + * @param string $configFile Configuration file path + * @param string $varFile Variable file path + * @param string $var Variable value (key=value) + * @param string $only Only build specified builders + * @param string $except Skip specified builders + * @param string $parallel Build in parallel + * @param string $color Enable color output + * @param string $debug Enable debug mode + * @param string $force Force build even if artifacts exist + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * artifacts: array, + * }>, + * }|string + */ + public function build( + string $configFile = '*.pkr.hcl', + string $varFile = '', + string $var = '', + string $only = '', + string $except = '', + string $parallel = 'true', + string $color = 'true', + string $debug = 'false', + string $force = 'false', + ): array|string { + try { + $command = ['packer', 'build']; + + if ($varFile) { + $command[] = "-var-file={$varFile}"; + } + + if ($var) { + $command[] = "-var={$var}"; + } + + if ($only) { + $command[] = "-only={$only}"; + } + + if ($except) { + $command[] = "-except={$except}"; + } + + if ('false' === $parallel) { + $command[] = '-parallel=false'; + } + + if ('false' === $color) { + $command[] = '-color=false'; + } + + if ('true' === $debug) { + $command[] = '-debug'; + } + + if ('true' === $force) { + $command[] = '-force'; + } + + $command[] = $configFile; + + $output = $this->executeCommand($command); + + // Parse artifacts from output (simplified) + $artifacts = []; + $lines = explode("\n", $output); + foreach ($lines as $line) { + if (str_contains($line, 'Build finished') || str_contains($line, 'Artifacts:')) { + // In a real implementation, you would parse the actual artifact information + // This is a simplified version + $artifacts[] = [ + 'type' => 'image', + 'builderId' => 'unknown', + 'id' => 'generated', + 'files' => ['artifact.file'], + ]; + } + } + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + 'artifacts' => $artifacts, + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + 'artifacts' => [], + ]; + } + } + + /** + * Format Packer configuration. + * + * @param string $configFile Configuration file path + * @param string $diff Show diff instead of modifying files + * @param string $check Check if files are formatted + * @param string $write Write formatted files + * @param string $recursive Process directories recursively + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * changed: bool, + * }|string + */ + public function fmt( + string $configFile = '.', + string $diff = 'false', + string $check = 'false', + string $write = 'true', + string $recursive = 'false', + ): array|string { + try { + $command = ['packer', 'fmt']; + + if ('true' === $diff) { + $command[] = '-diff'; + } + + if ('true' === $check) { + $command[] = '-check'; + } + + if ('false' === $write) { + $command[] = '-write=false'; + } + + if ('true' === $recursive) { + $command[] = '-recursive'; + } + + $command[] = $configFile; + + $output = $this->executeCommand($command); + + $changed = !str_contains($output, 'No changes'); + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + 'changed' => $changed, + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + 'changed' => false, + ]; + } + } + + /** + * Fix Packer configuration. + * + * @param string $configFile Configuration file path + * @param string $write Write fixed files + * @param string $diff Show diff instead of modifying files + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * fixed: bool, + * }|string + */ + public function fix( + string $configFile = '.', + string $write = 'true', + string $diff = 'false', + ): array|string { + try { + $command = ['packer', 'fix']; + + if ('false' === $write) { + $command[] = '-write=false'; + } + + if ('true' === $diff) { + $command[] = '-diff'; + } + + $command[] = $configFile; + + $output = $this->executeCommand($command); + + $fixed = !str_contains($output, 'No fixes needed'); + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + 'fixed' => $fixed, + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + 'fixed' => false, + ]; + } + } + + /** + * Inspect Packer configuration. + * + * @param string $configFile Configuration file path + * @param string $varFile Variable file path + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * template: array{ + * builders: array, + * provisioners: array, + * postProcessors: array, + * variables: array, + * }, + * }|string + */ + public function inspect( + string $configFile = '*.pkr.hcl', + string $varFile = '', + ): array|string { + try { + $command = ['packer', 'inspect']; + + if ($varFile) { + $command[] = "-var-file={$varFile}"; + } + + $command[] = $configFile; + + $output = $this->executeCommand($command); + + // Parse template information from output (simplified) + $template = [ + 'builders' => [], + 'provisioners' => [], + 'postProcessors' => [], + 'variables' => [], + ]; + + $lines = explode("\n", $output); + $currentSection = ''; + foreach ($lines as $line) { + $line = trim($line); + if (str_contains($line, 'Builders:')) { + $currentSection = 'builders'; + } elseif (str_contains($line, 'Provisioners:')) { + $currentSection = 'provisioners'; + } elseif (str_contains($line, 'Post-processors:')) { + $currentSection = 'postProcessors'; + } elseif (str_contains($line, 'Variables:')) { + $currentSection = 'variables'; + } elseif ($line && !str_contains($line, ':')) { + // Add item to current section (simplified parsing) + if ('builders' === $currentSection) { + $template['builders'][] = [ + 'name' => $line, + 'type' => 'unknown', + 'description' => '', + ]; + } + } + } + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + 'template' => $template, + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + 'template' => [ + 'builders' => [], + 'provisioners' => [], + 'postProcessors' => [], + 'variables' => [], + ], + ]; + } + } + + /** + * Show Packer version. + * + * @return array{ + * success: bool, + * version: string, + * output: string, + * error: string, + * }|string + */ + public function version(): array|string + { + try { + $command = ['packer', 'version']; + + $output = $this->executeCommand($command); + + // Extract version from output + $version = 'unknown'; + $lines = explode("\n", $output); + foreach ($lines as $line) { + if (preg_match('/Packer v(\d+\.\d+\.\d+)/', $line, $matches)) { + $version = $matches[1]; + break; + } + } + + return [ + 'success' => true, + 'version' => $version, + 'output' => $output, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'version' => 'unknown', + 'output' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Execute Packer command. + */ + private function executeCommand(array $command): string + { + $commandString = implode(' ', array_map('escapeshellarg', $command)); + + $output = []; + $returnCode = 0; + + exec("cd {$this->workingDirectory} && {$commandString} 2>&1", $output, $returnCode); + + if (0 !== $returnCode) { + throw new \RuntimeException('Packer command failed: '.implode("\n", $output)); + } + + return implode("\n", $output); + } +} diff --git a/src/agent/src/Toolbox/Tool/PassioNutrition.php b/src/agent/src/Toolbox/Tool/PassioNutrition.php new file mode 100644 index 000000000..8828d2e84 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/PassioNutrition.php @@ -0,0 +1,956 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('passio_nutrition_analyze_food', 'Tool that analyzes food using Passio Nutrition AI')] +#[AsTool('passio_nutrition_identify_food', 'Tool that identifies food items', method: 'identifyFood')] +#[AsTool('passio_nutrition_get_nutrition_facts', 'Tool that gets nutrition facts', method: 'getNutritionFacts')] +#[AsTool('passio_nutrition_calculate_calories', 'Tool that calculates calories', method: 'calculateCalories')] +#[AsTool('passio_nutrition_analyze_meal', 'Tool that analyzes complete meals', method: 'analyzeMeal')] +#[AsTool('passio_nutrition_suggest_alternatives', 'Tool that suggests food alternatives', method: 'suggestAlternatives')] +#[AsTool('passio_nutrition_track_intake', 'Tool that tracks nutritional intake', method: 'trackIntake')] +#[AsTool('passio_nutrition_generate_report', 'Tool that generates nutrition reports', method: 'generateReport')] +final readonly class PassioNutrition +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.passio.ai', + private array $options = [], + ) { + } + + /** + * Analyze food using Passio Nutrition AI. + * + * @param string $imageUrl URL or base64 encoded image + * @param array $analysisOptions Analysis options + * @param array $options Analysis options + * + * @return array{ + * success: bool, + * food_analysis: array{ + * image_url: string, + * analysis_options: array, + * detected_foods: array, + * }>, + * meal_summary: array{ + * total_calories: float, + * total_protein: float, + * total_carbs: float, + * total_fat: float, + * health_score: float, + * }, + * recommendations: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $imageUrl, + array $analysisOptions = [], + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'analysis_options' => array_merge([ + 'include_nutrition' => $analysisOptions['include_nutrition'] ?? true, + 'include_ingredients' => $analysisOptions['include_ingredients'] ?? true, + 'include_allergens' => $analysisOptions['include_allergens'] ?? true, + 'confidence_threshold' => $analysisOptions['confidence_threshold'] ?? 0.7, + ], $analysisOptions), + 'options' => array_merge([ + 'portion_estimation' => $options['portion_estimation'] ?? true, + 'health_scoring' => $options['health_scoring'] ?? true, + 'dietary_restrictions' => $options['dietary_restrictions'] ?? [], + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/nutrition/analyze", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $foods = $responseData['detected_foods'] ?? []; + + return [ + 'success' => !empty($foods), + 'food_analysis' => [ + 'image_url' => $imageUrl, + 'analysis_options' => $analysisOptions, + 'detected_foods' => array_map(fn ($food) => [ + 'food_name' => $food['name'] ?? '', + 'confidence' => $food['confidence'] ?? 0.0, + 'portion_size' => $food['portion_size'] ?? '', + 'bounding_box' => [ + 'x' => $food['bbox']['x'] ?? 0, + 'y' => $food['bbox']['y'] ?? 0, + 'width' => $food['bbox']['width'] ?? 0, + 'height' => $food['bbox']['height'] ?? 0, + ], + 'nutrition_info' => [ + 'calories' => $food['nutrition']['calories'] ?? 0.0, + 'protein' => $food['nutrition']['protein'] ?? 0.0, + 'carbs' => $food['nutrition']['carbs'] ?? 0.0, + 'fat' => $food['nutrition']['fat'] ?? 0.0, + 'fiber' => $food['nutrition']['fiber'] ?? 0.0, + 'sugar' => $food['nutrition']['sugar'] ?? 0.0, + 'sodium' => $food['nutrition']['sodium'] ?? 0.0, + ], + 'ingredients' => $food['ingredients'] ?? [], + ], $foods), + 'meal_summary' => [ + 'total_calories' => $responseData['meal_summary']['total_calories'] ?? 0.0, + 'total_protein' => $responseData['meal_summary']['total_protein'] ?? 0.0, + 'total_carbs' => $responseData['meal_summary']['total_carbs'] ?? 0.0, + 'total_fat' => $responseData['meal_summary']['total_fat'] ?? 0.0, + 'health_score' => $responseData['meal_summary']['health_score'] ?? 0.0, + ], + 'recommendations' => $responseData['recommendations'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'food_analysis' => [ + 'image_url' => $imageUrl, + 'analysis_options' => $analysisOptions, + 'detected_foods' => [], + 'meal_summary' => [ + 'total_calories' => 0.0, + 'total_protein' => 0.0, + 'total_carbs' => 0.0, + 'total_fat' => 0.0, + 'health_score' => 0.0, + ], + 'recommendations' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Identify food items. + * + * @param string $imageUrl URL or base64 encoded image + * @param array $foodCategories Food categories to detect + * @param array $options Identification options + * + * @return array{ + * success: bool, + * food_identification: array{ + * image_url: string, + * food_categories: array, + * identified_foods: array, + * identification_confidence: float, + * }, + * processingTime: float, + * error: string, + * } + */ + public function identifyFood( + string $imageUrl, + array $foodCategories = [], + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'food_categories' => $foodCategories ?: ['fruits', 'vegetables', 'grains', 'proteins', 'dairy', 'snacks'], + 'options' => array_merge([ + 'confidence_threshold' => $options['confidence_threshold'] ?? 0.7, + 'include_preparation' => $options['include_preparation'] ?? true, + 'include_brand_detection' => $options['include_brand'] ?? false, + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/nutrition/identify", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'food_identification' => [ + 'image_url' => $imageUrl, + 'food_categories' => $foodCategories, + 'identified_foods' => array_map(fn ($food) => [ + 'food_name' => $food['name'] ?? '', + 'category' => $food['category'] ?? '', + 'confidence' => $food['confidence'] ?? 0.0, + 'portion_size' => $food['portion_size'] ?? '', + 'bounding_box' => [ + 'x' => $food['bbox']['x'] ?? 0, + 'y' => $food['bbox']['y'] ?? 0, + 'width' => $food['bbox']['width'] ?? 0, + 'height' => $food['bbox']['height'] ?? 0, + ], + 'food_type' => $food['type'] ?? '', + 'preparation_method' => $food['preparation'] ?? '', + ], $responseData['foods'] ?? []), + 'identification_confidence' => $responseData['overall_confidence'] ?? 0.0, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'food_identification' => [ + 'image_url' => $imageUrl, + 'food_categories' => $foodCategories, + 'identified_foods' => [], + 'identification_confidence' => 0.0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get nutrition facts. + * + * @param string $foodName Name of the food + * @param string $portionSize Portion size + * @param array $options Nutrition options + * + * @return array{ + * success: bool, + * nutrition_facts: array{ + * food_name: string, + * portion_size: string, + * nutrition_data: array{ + * calories: float, + * macronutrients: array{ + * protein: array{ + * amount: float, + * unit: string, + * daily_value: float, + * }, + * carbohydrates: array{ + * amount: float, + * unit: string, + * daily_value: float, + * }, + * fat: array{ + * amount: float, + * unit: string, + * daily_value: float, + * }, + * }, + * micronutrients: array, + * fiber: float, + * sugar: float, + * sodium: float, + * }, + * health_indicators: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function getNutritionFacts( + string $foodName, + string $portionSize = '100g', + array $options = [], + ): array { + try { + $requestData = [ + 'food_name' => $foodName, + 'portion_size' => $portionSize, + 'options' => array_merge([ + 'include_micronutrients' => $options['include_micronutrients'] ?? true, + 'include_daily_values' => $options['include_daily_values'] ?? true, + 'database_source' => $options['database_source'] ?? 'usda', + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/nutrition/facts", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $nutrition = $responseData['nutrition'] ?? []; + + return [ + 'success' => true, + 'nutrition_facts' => [ + 'food_name' => $foodName, + 'portion_size' => $portionSize, + 'nutrition_data' => [ + 'calories' => $nutrition['calories'] ?? 0.0, + 'macronutrients' => [ + 'protein' => [ + 'amount' => $nutrition['protein']['amount'] ?? 0.0, + 'unit' => $nutrition['protein']['unit'] ?? 'g', + 'daily_value' => $nutrition['protein']['daily_value'] ?? 0.0, + ], + 'carbohydrates' => [ + 'amount' => $nutrition['carbs']['amount'] ?? 0.0, + 'unit' => $nutrition['carbs']['unit'] ?? 'g', + 'daily_value' => $nutrition['carbs']['daily_value'] ?? 0.0, + ], + 'fat' => [ + 'amount' => $nutrition['fat']['amount'] ?? 0.0, + 'unit' => $nutrition['fat']['unit'] ?? 'g', + 'daily_value' => $nutrition['fat']['daily_value'] ?? 0.0, + ], + ], + 'micronutrients' => $nutrition['micronutrients'] ?? [], + 'fiber' => $nutrition['fiber'] ?? 0.0, + 'sugar' => $nutrition['sugar'] ?? 0.0, + 'sodium' => $nutrition['sodium'] ?? 0.0, + ], + 'health_indicators' => $responseData['health_indicators'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'nutrition_facts' => [ + 'food_name' => $foodName, + 'portion_size' => $portionSize, + 'nutrition_data' => [ + 'calories' => 0.0, + 'macronutrients' => [ + 'protein' => ['amount' => 0.0, 'unit' => 'g', 'daily_value' => 0.0], + 'carbohydrates' => ['amount' => 0.0, 'unit' => 'g', 'daily_value' => 0.0], + 'fat' => ['amount' => 0.0, 'unit' => 'g', 'daily_value' => 0.0], + ], + 'micronutrients' => [], + 'fiber' => 0.0, + 'sugar' => 0.0, + 'sodium' => 0.0, + ], + 'health_indicators' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Calculate calories. + * + * @param string $imageUrl URL or base64 encoded image + * @param array $calculationOptions Calculation options + * @param array $options Calculation options + * + * @return array{ + * success: bool, + * calorie_calculation: array{ + * image_url: string, + * calculation_options: array, + * calorie_breakdown: array, + * total_calories: float, + * calorie_distribution: array{ + * proteins: float, + * carbohydrates: float, + * fats: float, + * }, + * accuracy_estimate: float, + * }, + * processingTime: float, + * error: string, + * } + */ + public function calculateCalories( + string $imageUrl, + array $calculationOptions = [], + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'calculation_options' => array_merge([ + 'include_portion_estimation' => $calculationOptions['include_portion'] ?? true, + 'include_cooking_methods' => $calculationOptions['include_cooking'] ?? true, + 'confidence_weighting' => $calculationOptions['confidence_weighting'] ?? true, + ], $calculationOptions), + 'options' => array_merge([ + 'precision_level' => $options['precision'] ?? 'medium', + 'include_breakdown' => $options['include_breakdown'] ?? true, + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/nutrition/calculate-calories", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'calorie_calculation' => [ + 'image_url' => $imageUrl, + 'calculation_options' => $calculationOptions, + 'calorie_breakdown' => array_map(fn ($food) => [ + 'food_name' => $food['name'] ?? '', + 'portion_size' => $food['portion'] ?? '', + 'calories' => $food['calories'] ?? 0.0, + 'confidence' => $food['confidence'] ?? 0.0, + ], $responseData['breakdown'] ?? []), + 'total_calories' => $responseData['total_calories'] ?? 0.0, + 'calorie_distribution' => [ + 'proteins' => $responseData['distribution']['protein'] ?? 0.0, + 'carbohydrates' => $responseData['distribution']['carbs'] ?? 0.0, + 'fats' => $responseData['distribution']['fat'] ?? 0.0, + ], + 'accuracy_estimate' => $responseData['accuracy'] ?? 0.0, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'calorie_calculation' => [ + 'image_url' => $imageUrl, + 'calculation_options' => $calculationOptions, + 'calorie_breakdown' => [], + 'total_calories' => 0.0, + 'calorie_distribution' => [ + 'proteins' => 0.0, + 'carbohydrates' => 0.0, + 'fats' => 0.0, + ], + 'accuracy_estimate' => 0.0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze complete meals. + * + * @param string $imageUrl URL or base64 encoded image + * @param array $mealAnalysisOptions Meal analysis options + * @param array $options Analysis options + * + * @return array{ + * success: bool, + * meal_analysis: array{ + * image_url: string, + * meal_analysis_options: array, + * meal_summary: array{ + * meal_type: string, + * total_calories: float, + * macronutrient_breakdown: array{ + * protein_percentage: float, + * carb_percentage: float, + * fat_percentage: float, + * }, + * health_score: float, + * balanced_score: float, + * }, + * food_items: array, + * }>, + * dietary_analysis: array{ + * allergens_detected: array, + * dietary_restrictions: array, + * health_benefits: array, + * concerns: array, + * }, + * recommendations: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function analyzeMeal( + string $imageUrl, + array $mealAnalysisOptions = [], + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'meal_analysis_options' => array_merge([ + 'include_health_scoring' => $mealAnalysisOptions['include_health'] ?? true, + 'include_allergen_detection' => $mealAnalysisOptions['include_allergens'] ?? true, + 'include_portion_analysis' => $mealAnalysisOptions['include_portions'] ?? true, + ], $mealAnalysisOptions), + 'options' => array_merge([ + 'dietary_preferences' => $options['dietary_preferences'] ?? [], + 'health_goals' => $options['health_goals'] ?? [], + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/nutrition/analyze-meal", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'meal_analysis' => [ + 'image_url' => $imageUrl, + 'meal_analysis_options' => $mealAnalysisOptions, + 'meal_summary' => [ + 'meal_type' => $responseData['meal_type'] ?? '', + 'total_calories' => $responseData['total_calories'] ?? 0.0, + 'macronutrient_breakdown' => [ + 'protein_percentage' => $responseData['macro_breakdown']['protein'] ?? 0.0, + 'carb_percentage' => $responseData['macro_breakdown']['carbs'] ?? 0.0, + 'fat_percentage' => $responseData['macro_breakdown']['fat'] ?? 0.0, + ], + 'health_score' => $responseData['health_score'] ?? 0.0, + 'balanced_score' => $responseData['balanced_score'] ?? 0.0, + ], + 'food_items' => array_map(fn ($food) => [ + 'food_name' => $food['name'] ?? '', + 'portion_size' => $food['portion'] ?? '', + 'calories' => $food['calories'] ?? 0.0, + 'nutrients' => $food['nutrients'] ?? [], + ], $responseData['food_items'] ?? []), + 'dietary_analysis' => [ + 'allergens_detected' => $responseData['allergens'] ?? [], + 'dietary_restrictions' => $responseData['dietary_restrictions'] ?? [], + 'health_benefits' => $responseData['health_benefits'] ?? [], + 'concerns' => $responseData['concerns'] ?? [], + ], + 'recommendations' => $responseData['recommendations'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'meal_analysis' => [ + 'image_url' => $imageUrl, + 'meal_analysis_options' => $mealAnalysisOptions, + 'meal_summary' => [ + 'meal_type' => '', + 'total_calories' => 0.0, + 'macronutrient_breakdown' => [ + 'protein_percentage' => 0.0, + 'carb_percentage' => 0.0, + 'fat_percentage' => 0.0, + ], + 'health_score' => 0.0, + 'balanced_score' => 0.0, + ], + 'food_items' => [], + 'dietary_analysis' => [ + 'allergens_detected' => [], + 'dietary_restrictions' => [], + 'health_benefits' => [], + 'concerns' => [], + ], + 'recommendations' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Suggest food alternatives. + * + * @param string $foodName Name of the food + * @param array $preferences User preferences + * @param array $options Alternative options + * + * @return array{ + * success: bool, + * food_alternatives: array{ + * original_food: string, + * preferences: array, + * alternatives: array, + * availability: string, + * }>, + * recommendation_reasoning: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function suggestAlternatives( + string $foodName, + array $preferences = [], + array $options = [], + ): array { + try { + $requestData = [ + 'food_name' => $foodName, + 'preferences' => array_merge([ + 'dietary_restrictions' => $preferences['dietary_restrictions'] ?? [], + 'health_goals' => $preferences['health_goals'] ?? [], + 'taste_preferences' => $preferences['taste_preferences'] ?? [], + ], $preferences), + 'options' => array_merge([ + 'max_alternatives' => $options['max_alternatives'] ?? 5, + 'include_nutrition_comparison' => $options['include_comparison'] ?? true, + 'similarity_threshold' => $options['similarity_threshold'] ?? 0.6, + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/nutrition/suggest-alternatives", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'food_alternatives' => [ + 'original_food' => $foodName, + 'preferences' => $preferences, + 'alternatives' => array_map(fn ($alt) => [ + 'food_name' => $alt['name'] ?? '', + 'similarity_score' => $alt['similarity'] ?? 0.0, + 'nutrition_comparison' => [ + 'calories_difference' => $alt['calories_diff'] ?? 0.0, + 'protein_difference' => $alt['protein_diff'] ?? 0.0, + 'carb_difference' => $alt['carb_diff'] ?? 0.0, + 'fat_difference' => $alt['fat_diff'] ?? 0.0, + ], + 'health_benefits' => $alt['health_benefits'] ?? [], + 'availability' => $alt['availability'] ?? '', + ], $responseData['alternatives'] ?? []), + 'recommendation_reasoning' => $responseData['reasoning'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'food_alternatives' => [ + 'original_food' => $foodName, + 'preferences' => $preferences, + 'alternatives' => [], + 'recommendation_reasoning' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Track nutritional intake. + * + * @param array> $foodItems Array of food items consumed + * @param array $trackingOptions Tracking options + * @param array $options Tracking options + * + * @return array{ + * success: bool, + * intake_tracking: array{ + * food_items: array>, + * tracking_options: array, + * daily_summary: array{ + * total_calories: float, + * macronutrients: array{ + * protein: float, + * carbohydrates: float, + * fat: float, + * }, + * micronutrients: array, + * meal_distribution: array, + * }, + * progress_analysis: array{ + * goal_comparison: array, + * trends: array, + * recommendations: array, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function trackIntake( + array $foodItems, + array $trackingOptions = [], + array $options = [], + ): array { + try { + $requestData = [ + 'food_items' => $foodItems, + 'tracking_options' => array_merge([ + 'include_micronutrients' => $trackingOptions['include_micronutrients'] ?? true, + 'include_meal_distribution' => $trackingOptions['include_meals'] ?? true, + 'include_progress_analysis' => $trackingOptions['include_progress'] ?? true, + ], $trackingOptions), + 'options' => array_merge([ + 'user_goals' => $options['user_goals'] ?? [], + 'tracking_period' => $options['tracking_period'] ?? 'daily', + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/nutrition/track-intake", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'intake_tracking' => [ + 'food_items' => $foodItems, + 'tracking_options' => $trackingOptions, + 'daily_summary' => [ + 'total_calories' => $responseData['daily_summary']['total_calories'] ?? 0.0, + 'macronutrients' => [ + 'protein' => $responseData['daily_summary']['protein'] ?? 0.0, + 'carbohydrates' => $responseData['daily_summary']['carbs'] ?? 0.0, + 'fat' => $responseData['daily_summary']['fat'] ?? 0.0, + ], + 'micronutrients' => $responseData['daily_summary']['micronutrients'] ?? [], + 'meal_distribution' => $responseData['daily_summary']['meal_distribution'] ?? [], + ], + 'progress_analysis' => [ + 'goal_comparison' => $responseData['progress']['goal_comparison'] ?? [], + 'trends' => $responseData['progress']['trends'] ?? [], + 'recommendations' => $responseData['progress']['recommendations'] ?? [], + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'intake_tracking' => [ + 'food_items' => $foodItems, + 'tracking_options' => $trackingOptions, + 'daily_summary' => [ + 'total_calories' => 0.0, + 'macronutrients' => [ + 'protein' => 0.0, + 'carbohydrates' => 0.0, + 'fat' => 0.0, + ], + 'micronutrients' => [], + 'meal_distribution' => [], + ], + 'progress_analysis' => [ + 'goal_comparison' => [], + 'trends' => [], + 'recommendations' => [], + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Generate nutrition reports. + * + * @param array $reportData Report data + * @param string $reportType Type of report + * @param array $options Report options + * + * @return array{ + * success: bool, + * nutrition_report: array{ + * report_data: array, + * report_type: string, + * report_content: array{ + * executive_summary: string, + * detailed_analysis: array, + * charts_data: array, + * recommendations: array, + * insights: array, + * }, + * report_metadata: array{ + * generated_at: string, + * report_period: string, + * data_points: int, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function generateReport( + array $reportData, + string $reportType = 'daily', + array $options = [], + ): array { + try { + $requestData = [ + 'report_data' => $reportData, + 'report_type' => $reportType, + 'options' => array_merge([ + 'include_charts' => $options['include_charts'] ?? true, + 'include_recommendations' => $options['include_recommendations'] ?? true, + 'include_insights' => $options['include_insights'] ?? true, + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/nutrition/generate-report", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'nutrition_report' => [ + 'report_data' => $reportData, + 'report_type' => $reportType, + 'report_content' => [ + 'executive_summary' => $responseData['summary'] ?? '', + 'detailed_analysis' => $responseData['analysis'] ?? [], + 'charts_data' => $responseData['charts'] ?? [], + 'recommendations' => $responseData['recommendations'] ?? [], + 'insights' => $responseData['insights'] ?? [], + ], + 'report_metadata' => [ + 'generated_at' => $responseData['generated_at'] ?? date('c'), + 'report_period' => $responseData['period'] ?? '', + 'data_points' => $responseData['data_points'] ?? 0, + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'nutrition_report' => [ + 'report_data' => $reportData, + 'report_type' => $reportType, + 'report_content' => [ + 'executive_summary' => '', + 'detailed_analysis' => [], + 'charts_data' => [], + 'recommendations' => [], + 'insights' => [], + ], + 'report_metadata' => [ + 'generated_at' => '', + 'report_period' => '', + 'data_points' => 0, + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/PayPal.php b/src/agent/src/Toolbox/Tool/PayPal.php new file mode 100644 index 000000000..8b36070e5 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/PayPal.php @@ -0,0 +1,594 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('paypal_create_order', 'Tool that creates PayPal orders')] +#[AsTool('paypal_capture_order', 'Tool that captures PayPal orders', method: 'captureOrder')] +#[AsTool('paypal_create_payment', 'Tool that creates PayPal payments', method: 'createPayment')] +#[AsTool('paypal_execute_payment', 'Tool that executes PayPal payments', method: 'executePayment')] +#[AsTool('paypal_refund_payment', 'Tool that refunds PayPal payments', method: 'refundPayment')] +#[AsTool('paypal_get_payment_details', 'Tool that gets PayPal payment details', method: 'getPaymentDetails')] +final readonly class PayPal +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $clientId, + #[\SensitiveParameter] private string $clientSecret, + private string $environment = 'sandbox', + private array $options = [], + ) { + } + + /** + * Create a PayPal order. + * + * @param string $intent Payment intent (CAPTURE, AUTHORIZE) + * @param array $items Order items + * @param array{ + * currency_code: string, + * value: string, + * breakdown: array{ + * item_total: array{currency_code: string, value: string}, + * tax_total: array{currency_code: string, value: string}|null, + * shipping: array{currency_code: string, value: string}|null, + * handling: array{currency_code: string, value: string}|null, + * shipping_discount: array{currency_code: string, value: string}|null, + * discount: array{currency_code: string, value: string}|null, + * }, + * } $amount Order amount + * @param string $currencyCode Currency code (e.g., 'USD', 'EUR') + * @param string $description Order description + * + * @return array{ + * id: string, + * status: string, + * links: array, + * create_time: string, + * update_time: string, + * }|string + */ + public function __invoke( + string $intent, + array $items, + array $amount, + string $currencyCode = 'USD', + string $description = '', + ): array|string { + try { + $accessToken = $this->getAccessToken(); + if (\is_string($accessToken)) { + return $accessToken; // Error getting access token + } + + $baseUrl = 'sandbox' === $this->environment + ? 'https://api.sandbox.paypal.com' + : 'https://api.paypal.com'; + + $payload = [ + 'intent' => $intent, + 'purchase_units' => [ + [ + 'amount' => $amount, + 'description' => $description, + 'items' => $items, + ], + ], + 'application_context' => [ + 'return_url' => $this->options['return_url'] ?? 'https://example.com/return', + 'cancel_url' => $this->options['cancel_url'] ?? 'https://example.com/cancel', + ], + ]; + + $response = $this->httpClient->request('POST', "{$baseUrl}/v2/checkout/orders", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + 'Content-Type' => 'application/json', + 'PayPal-Request-Id' => uniqid('pp-'), + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating order: '.($data['error_description'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'status' => $data['status'], + 'links' => $data['links'], + 'create_time' => $data['create_time'], + 'update_time' => $data['update_time'], + ]; + } catch (\Exception $e) { + return 'Error creating order: '.$e->getMessage(); + } + } + + /** + * Capture a PayPal order. + * + * @param string $orderId PayPal order ID + * + * @return array{ + * id: string, + * status: string, + * purchase_units: array}, + * seller_receivable_breakdown: array{ + * gross_amount: array{currency_code: string, value: string}, + * paypal_fee: array{currency_code: string, value: string}, + * net_amount: array{currency_code: string, value: string}, + * }, + * create_time: string, + * update_time: string, + * }>, + * }, + * }>, + * payer: array{ + * name: array{given_name: string, surname: string}, + * email_address: string, + * payer_id: string, + * }, + * links: array, + * }|string + */ + public function captureOrder(string $orderId): array|string + { + try { + $accessToken = $this->getAccessToken(); + if (\is_string($accessToken)) { + return $accessToken; // Error getting access token + } + + $baseUrl = 'sandbox' === $this->environment + ? 'https://api.sandbox.paypal.com' + : 'https://api.paypal.com'; + + $response = $this->httpClient->request('POST', "{$baseUrl}/v2/checkout/orders/{$orderId}/capture", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + 'Content-Type' => 'application/json', + 'PayPal-Request-Id' => uniqid('pp-'), + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error capturing order: '.($data['error_description'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'status' => $data['status'], + 'purchase_units' => $data['purchase_units'], + 'payer' => $data['payer'] ?? [], + 'links' => $data['links'] ?? [], + ]; + } catch (\Exception $e) { + return 'Error capturing order: '.$e->getMessage(); + } + } + + /** + * Create a PayPal payment (Legacy API). + * + * @param string $intent Payment intent (sale, authorize, order) + * @param string $currencyCode Currency code + * @param string $total Total amount + * @param string $description Payment description + * @param string $returnUrl Return URL + * @param string $cancelUrl Cancel URL + * + * @return array{ + * id: string, + * state: string, + * intent: string, + * payer: array{ + * payment_method: string, + * payer_info: array{ + * shipping_address: array{ + * line1: string, + * city: string, + * state: string, + * postal_code: string, + * country_code: string, + * }, + * }, + * }, + * transactions: array}, + * description: string, + * item_list: array{ + * items: array, + * }, + * }>, + * links: array, + * create_time: string, + * update_time: string, + * }|string + */ + public function createPayment( + string $intent, + string $currencyCode, + string $total, + string $description, + string $returnUrl, + string $cancelUrl, + ): array|string { + try { + $accessToken = $this->getAccessToken(); + if (\is_string($accessToken)) { + return $accessToken; // Error getting access token + } + + $baseUrl = 'sandbox' === $this->environment + ? 'https://api.sandbox.paypal.com' + : 'https://api.paypal.com'; + + $payload = [ + 'intent' => $intent, + 'redirect_urls' => [ + 'return_url' => $returnUrl, + 'cancel_url' => $cancelUrl, + ], + 'payer' => [ + 'payment_method' => 'paypal', + ], + 'transactions' => [ + [ + 'amount' => [ + 'total' => $total, + 'currency' => $currencyCode, + ], + 'description' => $description, + ], + ], + ]; + + $response = $this->httpClient->request('POST', "{$baseUrl}/v1/payments/payment", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating payment: '.($data['error_description'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'state' => $data['state'], + 'intent' => $data['intent'], + 'payer' => $data['payer'], + 'transactions' => $data['transactions'], + 'links' => $data['links'], + 'create_time' => $data['create_time'], + 'update_time' => $data['update_time'], + ]; + } catch (\Exception $e) { + return 'Error creating payment: '.$e->getMessage(); + } + } + + /** + * Execute a PayPal payment. + * + * @param string $paymentId PayPal payment ID + * @param string $payerId Payer ID from approval + * + * @return array{ + * id: string, + * state: string, + * intent: string, + * payer: array{ + * payment_method: string, + * status: string, + * payer_info: array{ + * email: string, + * first_name: string, + * last_name: string, + * payer_id: string, + * shipping_address: array{ + * line1: string, + * city: string, + * state: string, + * postal_code: string, + * country_code: string, + * }, + * }, + * }, + * transactions: array}, + * related_resources: array, + * }, + * }>, + * }>, + * create_time: string, + * update_time: string, + * links: array, + * }|string + */ + public function executePayment(string $paymentId, string $payerId): array|string + { + try { + $accessToken = $this->getAccessToken(); + if (\is_string($accessToken)) { + return $accessToken; // Error getting access token + } + + $baseUrl = 'sandbox' === $this->environment + ? 'https://api.sandbox.paypal.com' + : 'https://api.paypal.com'; + + $payload = [ + 'payer_id' => $payerId, + ]; + + $response = $this->httpClient->request('POST', "{$baseUrl}/v1/payments/payment/{$paymentId}/execute", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error executing payment: '.($data['error_description'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'state' => $data['state'], + 'intent' => $data['intent'], + 'payer' => $data['payer'], + 'transactions' => $data['transactions'], + 'create_time' => $data['create_time'], + 'update_time' => $data['update_time'], + 'links' => $data['links'], + ]; + } catch (\Exception $e) { + return 'Error executing payment: '.$e->getMessage(); + } + } + + /** + * Refund a PayPal payment. + * + * @param string $captureId Capture ID to refund + * @param string $amount Refund amount + * @param string $currencyCode Currency code + * @param string $reason Refund reason + * + * @return array{ + * id: string, + * amount: array{total: string, currency: string}, + * state: string, + * reason_code: string, + * parent_payment: string, + * create_time: string, + * update_time: string, + * links: array, + * }|string + */ + public function refundPayment( + string $captureId, + string $amount, + string $currencyCode, + string $reason = '', + ): array|string { + try { + $accessToken = $this->getAccessToken(); + if (\is_string($accessToken)) { + return $accessToken; // Error getting access token + } + + $baseUrl = 'sandbox' === $this->environment + ? 'https://api.sandbox.paypal.com' + : 'https://api.paypal.com'; + + $payload = [ + 'amount' => [ + 'total' => $amount, + 'currency' => $currencyCode, + ], + ]; + + if ($reason) { + $payload['reason'] = $reason; + } + + $response = $this->httpClient->request('POST', "{$baseUrl}/v1/payments/capture/{$captureId}/refund", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error refunding payment: '.($data['error_description'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'amount' => $data['amount'], + 'state' => $data['state'], + 'reason_code' => $data['reason_code'] ?? '', + 'parent_payment' => $data['parent_payment'], + 'create_time' => $data['create_time'], + 'update_time' => $data['update_time'], + 'links' => $data['links'], + ]; + } catch (\Exception $e) { + return 'Error refunding payment: '.$e->getMessage(); + } + } + + /** + * Get PayPal payment details. + * + * @param string $paymentId PayPal payment ID + * + * @return array{ + * id: string, + * state: string, + * intent: string, + * payer: array{ + * payment_method: string, + * status: string, + * payer_info: array{ + * email: string, + * first_name: string, + * last_name: string, + * payer_id: string, + * }, + * }, + * transactions: array}, + * description: string, + * related_resources: array>, + * }>, + * create_time: string, + * update_time: string, + * links: array, + * }|string + */ + public function getPaymentDetails(string $paymentId): array|string + { + try { + $accessToken = $this->getAccessToken(); + if (\is_string($accessToken)) { + return $accessToken; // Error getting access token + } + + $baseUrl = 'sandbox' === $this->environment + ? 'https://api.sandbox.paypal.com' + : 'https://api.paypal.com'; + + $response = $this->httpClient->request('GET', "{$baseUrl}/v1/payments/payment/{$paymentId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + 'Content-Type' => 'application/json', + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting payment details: '.($data['error_description'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'state' => $data['state'], + 'intent' => $data['intent'], + 'payer' => $data['payer'], + 'transactions' => $data['transactions'], + 'create_time' => $data['create_time'], + 'update_time' => $data['update_time'], + 'links' => $data['links'], + ]; + } catch (\Exception $e) { + return 'Error getting payment details: '.$e->getMessage(); + } + } + + /** + * Get PayPal access token. + * + * @return string|array{access_token: string, token_type: string, expires_in: int} + */ + private function getAccessToken(): string|array + { + try { + $baseUrl = 'sandbox' === $this->environment + ? 'https://api.sandbox.paypal.com' + : 'https://api.paypal.com'; + + $response = $this->httpClient->request('POST', "{$baseUrl}/v1/oauth2/token", [ + 'headers' => [ + 'Accept' => 'application/json', + 'Accept-Language' => 'en_US', + ], + 'auth_basic' => [$this->clientId, $this->clientSecret], + 'body' => 'grant_type=client_credentials', + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting access token: '.($data['error_description'] ?? 'Unknown error'); + } + + return $data; + } catch (\Exception $e) { + return 'Error getting access token: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Pinterest.php b/src/agent/src/Toolbox/Tool/Pinterest.php new file mode 100644 index 000000000..9aea40851 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Pinterest.php @@ -0,0 +1,635 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('pinterest_search_pins', 'Tool that searches Pinterest pins')] +#[AsTool('pinterest_get_user_boards', 'Tool that gets Pinterest user boards', method: 'getUserBoards')] +#[AsTool('pinterest_get_board_pins', 'Tool that gets Pinterest board pins', method: 'getBoardPins')] +#[AsTool('pinterest_create_pin', 'Tool that creates Pinterest pins', method: 'createPin')] +#[AsTool('pinterest_get_user_info', 'Tool that gets Pinterest user information', method: 'getUserInfo')] +#[AsTool('pinterest_search_boards', 'Tool that searches Pinterest boards', method: 'searchBoards')] +final readonly class Pinterest +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private string $apiVersion = 'v1', + private array $options = [], + ) { + } + + /** + * Search Pinterest pins. + * + * @param string $query Search query + * @param int $limit Number of results (1-250) + * @param string $bookmark Pagination bookmark + * @param string $sort Sort order (popular, newest) + * + * @return array + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + int $limit = 25, + string $bookmark = '', + string $sort = 'popular', + ): array { + try { + $params = [ + 'query' => $query, + 'limit' => min(max($limit, 1), 250), + 'sort' => $sort, + ]; + + if ($bookmark) { + $params['bookmark'] = $bookmark; + } + + $response = $this->httpClient->request('GET', "https://api.pinterest.com/{$this->apiVersion}/search/pins", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return []; + } + + $pins = []; + foreach ($data['data'] as $pin) { + $pins[] = [ + 'id' => $pin['id'], + 'title' => $pin['title'] ?? '', + 'description' => $pin['description'] ?? '', + 'url' => $pin['url'] ?? '', + 'image' => [ + 'original' => [ + 'url' => $pin['image']['original']['url'] ?? '', + 'width' => $pin['image']['original']['width'] ?? 0, + 'height' => $pin['image']['original']['height'] ?? 0, + ], + 'small' => [ + 'url' => $pin['image']['small']['url'] ?? '', + 'width' => $pin['image']['small']['width'] ?? 0, + 'height' => $pin['image']['small']['height'] ?? 0, + ], + 'medium' => [ + 'url' => $pin['image']['medium']['url'] ?? '', + 'width' => $pin['image']['medium']['width'] ?? 0, + 'height' => $pin['image']['medium']['height'] ?? 0, + ], + ], + 'board' => [ + 'id' => $pin['board']['id'] ?? '', + 'name' => $pin['board']['name'] ?? '', + 'url' => $pin['board']['url'] ?? '', + ], + 'creator' => [ + 'id' => $pin['creator']['id'] ?? '', + 'username' => $pin['creator']['username'] ?? '', + 'first_name' => $pin['creator']['first_name'] ?? '', + 'last_name' => $pin['creator']['last_name'] ?? '', + 'image' => [ + 'small' => [ + 'url' => $pin['creator']['image']['small']['url'] ?? '', + ], + ], + ], + 'counts' => [ + 'saves' => $pin['counts']['saves'] ?? 0, + 'comments' => $pin['counts']['comments'] ?? 0, + ], + 'created_at' => $pin['created_at'] ?? '', + 'link' => $pin['link'] ?? '', + 'note' => $pin['note'] ?? '', + 'color' => $pin['color'] ?? '', + ]; + } + + return $pins; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Pinterest user boards. + * + * @param string $username Pinterest username + * @param int $limit Number of results (1-250) + * @param string $bookmark Pagination bookmark + * + * @return array + */ + public function getUserBoards( + string $username, + int $limit = 25, + string $bookmark = '', + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 250), + ]; + + if ($bookmark) { + $params['bookmark'] = $bookmark; + } + + $response = $this->httpClient->request('GET', "https://api.pinterest.com/{$this->apiVersion}/users/{$username}/boards", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return []; + } + + $boards = []; + foreach ($data['data'] as $board) { + $boards[] = [ + 'id' => $board['id'], + 'name' => $board['name'], + 'description' => $board['description'] ?? '', + 'url' => $board['url'], + 'creator' => [ + 'id' => $board['creator']['id'] ?? '', + 'username' => $board['creator']['username'] ?? '', + 'first_name' => $board['creator']['first_name'] ?? '', + 'last_name' => $board['creator']['last_name'] ?? '', + ], + 'counts' => [ + 'pins' => $board['counts']['pins'] ?? 0, + 'collaborators' => $board['counts']['collaborators'] ?? 0, + 'followers' => $board['counts']['followers'] ?? 0, + ], + 'created_at' => $board['created_at'], + 'privacy' => $board['privacy'] ?? 'public', + 'reason' => $board['reason'] ?? '', + ]; + } + + return $boards; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Pinterest board pins. + * + * @param string $boardId Pinterest board ID + * @param int $limit Number of results (1-250) + * @param string $bookmark Pagination bookmark + * + * @return array + */ + public function getBoardPins( + string $boardId, + int $limit = 25, + string $bookmark = '', + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 250), + ]; + + if ($bookmark) { + $params['bookmark'] = $bookmark; + } + + $response = $this->httpClient->request('GET', "https://api.pinterest.com/{$this->apiVersion}/boards/{$boardId}/pins", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return []; + } + + $pins = []; + foreach ($data['data'] as $pin) { + $pins[] = [ + 'id' => $pin['id'], + 'title' => $pin['title'] ?? '', + 'description' => $pin['description'] ?? '', + 'url' => $pin['url'] ?? '', + 'image' => [ + 'original' => [ + 'url' => $pin['image']['original']['url'] ?? '', + 'width' => $pin['image']['original']['width'] ?? 0, + 'height' => $pin['image']['original']['height'] ?? 0, + ], + 'small' => [ + 'url' => $pin['image']['small']['url'] ?? '', + 'width' => $pin['image']['small']['width'] ?? 0, + 'height' => $pin['image']['small']['height'] ?? 0, + ], + 'medium' => [ + 'url' => $pin['image']['medium']['url'] ?? '', + 'width' => $pin['image']['medium']['width'] ?? 0, + 'height' => $pin['image']['medium']['height'] ?? 0, + ], + ], + 'board' => [ + 'id' => $pin['board']['id'] ?? '', + 'name' => $pin['board']['name'] ?? '', + 'url' => $pin['board']['url'] ?? '', + ], + 'creator' => [ + 'id' => $pin['creator']['id'] ?? '', + 'username' => $pin['creator']['username'] ?? '', + 'first_name' => $pin['creator']['first_name'] ?? '', + 'last_name' => $pin['creator']['last_name'] ?? '', + ], + 'counts' => [ + 'saves' => $pin['counts']['saves'] ?? 0, + 'comments' => $pin['counts']['comments'] ?? 0, + ], + 'created_at' => $pin['created_at'] ?? '', + 'link' => $pin['link'] ?? '', + 'note' => $pin['note'] ?? '', + ]; + } + + return $pins; + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a Pinterest pin. + * + * @param string $boardId Pinterest board ID + * @param string $imageUrl URL of the image to pin + * @param string $title Pin title + * @param string $description Pin description + * @param string $link Optional link URL + * @param string $note Optional note + * + * @return array{ + * id: string, + * title: string, + * description: string, + * url: string, + * image: array{ + * original: array{url: string, width: int, height: int}, + * }, + * board: array{ + * id: string, + * name: string, + * }, + * creator: array{ + * id: string, + * username: string, + * }, + * counts: array{ + * saves: int, + * comments: int, + * }, + * created_at: string, + * link: string, + * note: string, + * }|string + */ + public function createPin( + string $boardId, + string $imageUrl, + string $title, + string $description = '', + string $link = '', + string $note = '', + ): array|string { + try { + $payload = [ + 'board_id' => $boardId, + 'image_url' => $imageUrl, + 'title' => $title, + ]; + + if ($description) { + $payload['description'] = $description; + } + + if ($link) { + $payload['link'] = $link; + } + + if ($note) { + $payload['note'] = $note; + } + + $response = $this->httpClient->request('POST', "https://api.pinterest.com/{$this->apiVersion}/pins", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating pin: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'title' => $data['title'], + 'description' => $data['description'] ?? '', + 'url' => $data['url'], + 'image' => [ + 'original' => [ + 'url' => $data['image']['original']['url'], + 'width' => $data['image']['original']['width'], + 'height' => $data['image']['original']['height'], + ], + ], + 'board' => [ + 'id' => $data['board']['id'], + 'name' => $data['board']['name'], + ], + 'creator' => [ + 'id' => $data['creator']['id'], + 'username' => $data['creator']['username'], + ], + 'counts' => [ + 'saves' => $data['counts']['saves'] ?? 0, + 'comments' => $data['counts']['comments'] ?? 0, + ], + 'created_at' => $data['created_at'], + 'link' => $data['link'] ?? '', + 'note' => $data['note'] ?? '', + ]; + } catch (\Exception $e) { + return 'Error creating pin: '.$e->getMessage(); + } + } + + /** + * Get Pinterest user information. + * + * @param string $username Pinterest username + * + * @return array{ + * id: string, + * username: string, + * first_name: string, + * last_name: string, + * bio: string, + * created_at: string, + * counts: array{ + * pins: int, + * following: int, + * followers: int, + * boards: int, + * likes: int, + * }, + * image: array{ + * small: array{url: string}, + * large: array{url: string}, + * }, + * website: string, + * location: string, + * account_type: string, + * }|string + */ + public function getUserInfo(string $username): array|string + { + try { + $response = $this->httpClient->request('GET', "https://api.pinterest.com/{$this->apiVersion}/users/{$username}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting user info: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'username' => $data['username'], + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'bio' => $data['bio'] ?? '', + 'created_at' => $data['created_at'], + 'counts' => [ + 'pins' => $data['counts']['pins'] ?? 0, + 'following' => $data['counts']['following'] ?? 0, + 'followers' => $data['counts']['followers'] ?? 0, + 'boards' => $data['counts']['boards'] ?? 0, + 'likes' => $data['counts']['likes'] ?? 0, + ], + 'image' => [ + 'small' => [ + 'url' => $data['image']['small']['url'] ?? '', + ], + 'large' => [ + 'url' => $data['image']['large']['url'] ?? '', + ], + ], + 'website' => $data['website'] ?? '', + 'location' => $data['location'] ?? '', + 'account_type' => $data['account_type'] ?? 'personal', + ]; + } catch (\Exception $e) { + return 'Error getting user info: '.$e->getMessage(); + } + } + + /** + * Search Pinterest boards. + * + * @param string $query Search query + * @param int $limit Number of results (1-250) + * @param string $bookmark Pagination bookmark + * + * @return array + */ + public function searchBoards( + #[With(maximum: 500)] + string $query, + int $limit = 25, + string $bookmark = '', + ): array { + try { + $params = [ + 'query' => $query, + 'limit' => min(max($limit, 1), 250), + ]; + + if ($bookmark) { + $params['bookmark'] = $bookmark; + } + + $response = $this->httpClient->request('GET', "https://api.pinterest.com/{$this->apiVersion}/search/boards", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return []; + } + + $boards = []; + foreach ($data['data'] as $board) { + $boards[] = [ + 'id' => $board['id'], + 'name' => $board['name'], + 'description' => $board['description'] ?? '', + 'url' => $board['url'], + 'creator' => [ + 'id' => $board['creator']['id'] ?? '', + 'username' => $board['creator']['username'] ?? '', + 'first_name' => $board['creator']['first_name'] ?? '', + 'last_name' => $board['creator']['last_name'] ?? '', + ], + 'counts' => [ + 'pins' => $board['counts']['pins'] ?? 0, + 'collaborators' => $board['counts']['collaborators'] ?? 0, + 'followers' => $board['counts']['followers'] ?? 0, + ], + 'created_at' => $board['created_at'], + 'privacy' => $board['privacy'] ?? 'public', + ]; + } + + return $boards; + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/PlaywrightBrowser.php b/src/agent/src/Toolbox/Tool/PlaywrightBrowser.php new file mode 100644 index 000000000..fa3b58b4a --- /dev/null +++ b/src/agent/src/Toolbox/Tool/PlaywrightBrowser.php @@ -0,0 +1,237 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +/** + * @author Mathieu Ledru + */ +#[AsTool('navigate_browser', 'Tool for navigating a browser to a URL')] +#[AsTool('extract_text', 'Tool for extracting all text from the current webpage', method: 'extractText')] +#[AsTool('click_element', 'Tool for clicking on an element on the webpage', method: 'clickElement')] +#[AsTool('take_screenshot', 'Tool for taking a screenshot of the current page', method: 'takeScreenshot')] +final readonly class PlaywrightBrowser +{ + public function __construct( + private bool $allowDangerousNavigation = false, + private string $browserType = 'chromium', + private bool $headless = true, + private string $outputDir = '/tmp', + ) { + if (!$this->allowDangerousNavigation) { + throw new \InvalidArgumentException('You must set allowDangerousNavigation to true to use this tool. Browser automation can be dangerous and can lead to security vulnerabilities. This tool can navigate to any URL, including internal network URLs.'); + } + } + + /** + * Navigate a browser to the specified URL. + * + * @param string $url The URL to navigate to + */ + public function __invoke(string $url): string + { + try { + // Validate URL scheme + $parsedUrl = parse_url($url); + if (!$parsedUrl || !isset($parsedUrl['scheme']) || !\in_array($parsedUrl['scheme'], ['http', 'https'])) { + return 'Error: URL scheme must be http or https'; + } + + // Create a simple navigation script + $script = $this->createNavigationScript($url); + $scriptPath = $this->outputDir.'/navigate_'.uniqid().'.js'; + + file_put_contents($scriptPath, $script); + + // Execute the Playwright script using exec + $command = 'npx playwright test '.escapeshellarg($scriptPath).' 2>&1'; + $output = []; + $returnCode = 0; + exec($command, $output, $returnCode); + + // Clean up + unlink($scriptPath); + + if (0 === $returnCode) { + return "Successfully navigated to {$url}"; + } else { + return 'Navigation failed: '.implode("\n", $output); + } + } catch (\Exception $e) { + return 'Error navigating to URL: '.$e->getMessage(); + } + } + + /** + * Extract all text from the current webpage. + * + * @param string $url The URL to extract text from + */ + public function extractText(string $url): string + { + try { + $script = $this->createTextExtractionScript($url); + $scriptPath = $this->outputDir.'/extract_'.uniqid().'.js'; + + file_put_contents($scriptPath, $script); + + $command = 'npx playwright test '.escapeshellarg($scriptPath).' 2>&1'; + $output = []; + $returnCode = 0; + exec($command, $output, $returnCode); + + unlink($scriptPath); + + if (0 === $returnCode) { + $outputString = implode("\n", $output); + // Extract text content from the output + if (preg_match('/TEXT_CONTENT_START(.*?)TEXT_CONTENT_END/s', $outputString, $matches)) { + return trim($matches[1]); + } + + return 'No text content found'; + } else { + return 'Text extraction failed: '.implode("\n", $output); + } + } catch (\Exception $e) { + return 'Error extracting text: '.$e->getMessage(); + } + } + + /** + * Click on an element on the webpage. + * + * @param string $url The URL of the page + * @param string $selector CSS selector for the element to click + */ + public function clickElement(string $url, string $selector): string + { + try { + $script = $this->createClickScript($url, $selector); + $scriptPath = $this->outputDir.'/click_'.uniqid().'.js'; + + file_put_contents($scriptPath, $script); + + $command = 'npx playwright test '.escapeshellarg($scriptPath).' 2>&1'; + $output = []; + $returnCode = 0; + exec($command, $output, $returnCode); + + unlink($scriptPath); + + if (0 === $returnCode) { + return "Successfully clicked element: {$selector}"; + } else { + return 'Click failed: '.implode("\n", $output); + } + } catch (\Exception $e) { + return 'Error clicking element: '.$e->getMessage(); + } + } + + /** + * Take a screenshot of the current page. + * + * @param string $url The URL to screenshot + */ + public function takeScreenshot(string $url): string + { + try { + $screenshotPath = $this->outputDir.'/screenshot_'.uniqid().'.png'; + $script = $this->createScreenshotScript($url, $screenshotPath); + $scriptPath = $this->outputDir.'/screenshot_'.uniqid().'.js'; + + file_put_contents($scriptPath, $script); + + $command = 'npx playwright test '.escapeshellarg($scriptPath).' 2>&1'; + $output = []; + $returnCode = 0; + exec($command, $output, $returnCode); + + unlink($scriptPath); + + if (0 === $returnCode && file_exists($screenshotPath)) { + return "Screenshot saved to: {$screenshotPath}"; + } else { + return 'Screenshot failed: '.implode("\n", $output); + } + } catch (\Exception $e) { + return 'Error taking screenshot: '.$e->getMessage(); + } + } + + /** + * Create navigation script. + */ + private function createNavigationScript(string $url): string + { + return " +const { test, expect } = require('@playwright/test'); + +test('navigate to {$url}', async ({ page }) => { + const response = await page.goto('{$url}'); + console.log('Navigation status:', response?.status()); +}); +"; + } + + /** + * Create text extraction script. + */ + private function createTextExtractionScript(string $url): string + { + return " +const { test, expect } = require('@playwright/test'); + +test('extract text from {$url}', async ({ page }) => { + await page.goto('{$url}'); + const text = await page.textContent('body'); + console.log('TEXT_CONTENT_START'); + console.log(text); + console.log('TEXT_CONTENT_END'); +}); +"; + } + + /** + * Create click script. + */ + private function createClickScript(string $url, string $selector): string + { + return " +const { test, expect } = require('@playwright/test'); + +test('click element on {$url}', async ({ page }) => { + await page.goto('{$url}'); + await page.click('{$selector}'); + console.log('Element clicked successfully'); +}); +"; + } + + /** + * Create screenshot script. + */ + private function createScreenshotScript(string $url, string $screenshotPath): string + { + return " +const { test, expect } = require('@playwright/test'); + +test('take screenshot of {$url}', async ({ page }) => { + await page.goto('{$url}'); + await page.screenshot({ path: '{$screenshotPath}' }); + console.log('Screenshot taken'); +}); +"; + } +} diff --git a/src/agent/src/Toolbox/Tool/Polygon.php b/src/agent/src/Toolbox/Tool/Polygon.php new file mode 100644 index 000000000..ac1c9cab6 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Polygon.php @@ -0,0 +1,731 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('polygon_get_ticker_details', 'Tool that gets Polygon ticker details')] +#[AsTool('polygon_get_ticker_news', 'Tool that gets Polygon ticker news', method: 'getTickerNews')] +#[AsTool('polygon_get_ticker_trades', 'Tool that gets Polygon ticker trades', method: 'getTickerTrades')] +#[AsTool('polygon_get_ticker_quotes', 'Tool that gets Polygon ticker quotes', method: 'getTickerQuotes')] +#[AsTool('polygon_get_aggregates', 'Tool that gets Polygon aggregates', method: 'getAggregates')] +#[AsTool('polygon_get_grouped_daily', 'Tool that gets Polygon grouped daily data', method: 'getGroupedDaily')] +#[AsTool('polygon_get_market_status', 'Tool that gets Polygon market status', method: 'getMarketStatus')] +#[AsTool('polygon_get_exchanges', 'Tool that gets Polygon exchanges', method: 'getExchanges')] +final readonly class Polygon +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.polygon.io', + private array $options = [], + ) { + } + + /** + * Get Polygon ticker details. + * + * @param string $ticker Stock ticker symbol + * @param string $date Date (YYYY-MM-DD) + * + * @return array{ + * success: bool, + * ticker: array{ + * ticker: string, + * name: string, + * market: string, + * locale: string, + * primaryExchange: string, + * type: string, + * active: bool, + * currencyName: string, + * currencySymbol: string, + * baseCurrencySymbol: string, + * baseCurrencyName: string, + * cik: string, + * compositeFigi: string, + * shareClassFigi: string, + * marketCap: float, + * description: string, + * homepageUrl: string, + * totalEmployees: int, + * listDate: string, + * branding: array{ + * logoUrl: string, + * iconUrl: string, + * }, + * shareClassSharesOutstanding: int, + * weightedSharesOutstanding: int, + * roundLot: int, + * }, + * error: string, + * } + */ + public function __invoke( + string $ticker, + string $date = '', + ): array { + try { + $params = [ + 'apikey' => $this->apiKey, + ]; + + if ($date) { + $params['date'] = $date; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/v3/reference/tickers/{$ticker}", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + $result = $data['results'] ?? []; + + return [ + 'success' => true, + 'ticker' => [ + 'ticker' => $result['ticker'] ?? $ticker, + 'name' => $result['name'] ?? '', + 'market' => $result['market'] ?? '', + 'locale' => $result['locale'] ?? '', + 'primaryExchange' => $result['primary_exchange'] ?? '', + 'type' => $result['type'] ?? '', + 'active' => $result['active'] ?? false, + 'currencyName' => $result['currency_name'] ?? '', + 'currencySymbol' => $result['currency_symbol'] ?? '', + 'baseCurrencySymbol' => $result['base_currency_symbol'] ?? '', + 'baseCurrencyName' => $result['base_currency_name'] ?? '', + 'cik' => $result['cik'] ?? '', + 'compositeFigi' => $result['composite_figi'] ?? '', + 'shareClassFigi' => $result['share_class_figi'] ?? '', + 'marketCap' => $result['market_cap'] ?? 0.0, + 'description' => $result['description'] ?? '', + 'homepageUrl' => $result['homepage_url'] ?? '', + 'totalEmployees' => $result['total_employees'] ?? 0, + 'listDate' => $result['list_date'] ?? '', + 'branding' => [ + 'logoUrl' => $result['branding']['logo_url'] ?? '', + 'iconUrl' => $result['branding']['icon_url'] ?? '', + ], + 'shareClassSharesOutstanding' => $result['share_class_shares_outstanding'] ?? 0, + 'weightedSharesOutstanding' => $result['weighted_shares_outstanding'] ?? 0, + 'roundLot' => $result['round_lot'] ?? 0, + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'ticker' => [ + 'ticker' => $ticker, + 'name' => '', + 'market' => '', + 'locale' => '', + 'primaryExchange' => '', + 'type' => '', + 'active' => false, + 'currencyName' => '', + 'currencySymbol' => '', + 'baseCurrencySymbol' => '', + 'baseCurrencyName' => '', + 'cik' => '', + 'compositeFigi' => '', + 'shareClassFigi' => '', + 'marketCap' => 0.0, + 'description' => '', + 'homepageUrl' => '', + 'totalEmployees' => 0, + 'listDate' => '', + 'branding' => ['logoUrl' => '', 'iconUrl' => ''], + 'shareClassSharesOutstanding' => 0, + 'weightedSharesOutstanding' => 0, + 'roundLot' => 0, + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Polygon ticker news. + * + * @param string $ticker Stock ticker symbol + * @param string $publishedUtc Published date (YYYY-MM-DD) + * @param string $publishedUtcLt Published date less than (YYYY-MM-DD) + * @param string $publishedUtcGt Published date greater than (YYYY-MM-DD) + * @param int $limit Number of results + * @param string $sort Sort order (published_utc, -published_utc) + * @param string $order Order (asc, desc) + * + * @return array{ + * success: bool, + * news: array, + * ampUrl: string, + * imageUrl: string, + * description: string, + * keywords: array, + * }>, + * count: int, + * error: string, + * } + */ + public function getTickerNews( + string $ticker, + string $publishedUtc = '', + string $publishedUtcLt = '', + string $publishedUtcGt = '', + int $limit = 10, + string $sort = 'published_utc', + string $order = 'desc', + ): array { + try { + $params = [ + 'apikey' => $this->apiKey, + 'ticker' => $ticker, + 'limit' => max(1, min($limit, 1000)), + 'sort' => $sort, + 'order' => $order, + ]; + + if ($publishedUtc) { + $params['published_utc'] = $publishedUtc; + } + + if ($publishedUtcLt) { + $params['published_utc.lt'] = $publishedUtcLt; + } + + if ($publishedUtcGt) { + $params['published_utc.gt'] = $publishedUtcGt; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/v2/reference/news", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'news' => array_map(fn ($article) => [ + 'id' => $article['id'] ?? '', + 'publisher' => [ + 'name' => $article['publisher']['name'] ?? '', + 'homepageUrl' => $article['publisher']['homepage_url'] ?? '', + 'logoUrl' => $article['publisher']['logo_url'] ?? '', + 'faviconUrl' => $article['publisher']['favicon_url'] ?? '', + ], + 'title' => $article['title'] ?? '', + 'author' => $article['author'] ?? '', + 'publishedUtc' => $article['published_utc'] ?? '', + 'articleUrl' => $article['article_url'] ?? '', + 'tickers' => $article['tickers'] ?? [], + 'ampUrl' => $article['amp_url'] ?? '', + 'imageUrl' => $article['image_url'] ?? '', + 'description' => $article['description'] ?? '', + 'keywords' => $article['keywords'] ?? [], + ], $data['results'] ?? []), + 'count' => $data['count'] ?? 0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'news' => [], + 'count' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Polygon ticker trades. + * + * @param string $ticker Stock ticker symbol + * @param string $timestamp Timestamp (YYYY-MM-DD or Unix timestamp) + * @param string $timestampLt Timestamp less than + * @param string $timestampGt Timestamp greater than + * @param int $limit Number of results + * @param string $order Order (asc, desc) + * @param string $sort Sort field + * + * @return array{ + * success: bool, + * trades: array, + * exchange: string, + * price: float, + * sipTimestamp: int, + * size: int, + * timeframe: string, + * participantTimestamp: int, + * sequenceNumber: int, + * trfId: int, + * trfTimestamp: int, + * yahoo: bool, + * }>, + * count: int, + * error: string, + * } + */ + public function getTickerTrades( + string $ticker, + string $timestamp = '', + string $timestampLt = '', + string $timestampGt = '', + int $limit = 10, + string $order = 'desc', + string $sort = 'timestamp', + ): array { + try { + $params = [ + 'apikey' => $this->apiKey, + 'limit' => max(1, min($limit, 50000)), + 'order' => $order, + 'sort' => $sort, + ]; + + if ($timestamp) { + $params['timestamp'] = $timestamp; + } + + if ($timestampLt) { + $params['timestamp.lt'] = $timestampLt; + } + + if ($timestampGt) { + $params['timestamp.gt'] = $timestampGt; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/v3/trades/{$ticker}", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'trades' => array_map(fn ($trade) => [ + 'conditions' => $trade['conditions'] ?? [], + 'exchange' => $trade['exchange'] ?? '', + 'price' => $trade['price'] ?? 0.0, + 'sipTimestamp' => $trade['sip_timestamp'] ?? 0, + 'size' => $trade['size'] ?? 0, + 'timeframe' => $trade['timeframe'] ?? '', + 'participantTimestamp' => $trade['participant_timestamp'] ?? 0, + 'sequenceNumber' => $trade['sequence_number'] ?? 0, + 'trfId' => $trade['trf_id'] ?? 0, + 'trfTimestamp' => $trade['trf_timestamp'] ?? 0, + 'yahoo' => $trade['yahoo'] ?? false, + ], $data['results'] ?? []), + 'count' => $data['count'] ?? 0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'trades' => [], + 'count' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Polygon ticker quotes. + * + * @param string $ticker Stock ticker symbol + * @param string $timestamp Timestamp (YYYY-MM-DD or Unix timestamp) + * @param string $timestampLt Timestamp less than + * @param string $timestampGt Timestamp greater than + * @param int $limit Number of results + * @param string $order Order (asc, desc) + * @param string $sort Sort field + * + * @return array{ + * success: bool, + * quotes: array, + * exchange: string, + * ask: float, + * askSize: int, + * bid: float, + * bidSize: int, + * participantTimestamp: int, + * sequenceNumber: int, + * sipTimestamp: int, + * timeframe: string, + * trfId: int, + * trfTimestamp: int, + * yahoo: bool, + * }>, + * count: int, + * error: string, + * } + */ + public function getTickerQuotes( + string $ticker, + string $timestamp = '', + string $timestampLt = '', + string $timestampGt = '', + int $limit = 10, + string $order = 'desc', + string $sort = 'timestamp', + ): array { + try { + $params = [ + 'apikey' => $this->apiKey, + 'limit' => max(1, min($limit, 50000)), + 'order' => $order, + 'sort' => $sort, + ]; + + if ($timestamp) { + $params['timestamp'] = $timestamp; + } + + if ($timestampLt) { + $params['timestamp.lt'] = $timestampLt; + } + + if ($timestampGt) { + $params['timestamp.gt'] = $timestampGt; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/v3/quotes/{$ticker}", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'quotes' => array_map(fn ($quote) => [ + 'conditions' => $quote['conditions'] ?? [], + 'exchange' => $quote['exchange'] ?? '', + 'ask' => $quote['ask'] ?? 0.0, + 'askSize' => $quote['ask_size'] ?? 0, + 'bid' => $quote['bid'] ?? 0.0, + 'bidSize' => $quote['bid_size'] ?? 0, + 'participantTimestamp' => $quote['participant_timestamp'] ?? 0, + 'sequenceNumber' => $quote['sequence_number'] ?? 0, + 'sipTimestamp' => $quote['sip_timestamp'] ?? 0, + 'timeframe' => $quote['timeframe'] ?? '', + 'trfId' => $quote['trf_id'] ?? 0, + 'trfTimestamp' => $quote['trf_timestamp'] ?? 0, + 'yahoo' => $quote['yahoo'] ?? false, + ], $data['results'] ?? []), + 'count' => $data['count'] ?? 0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'quotes' => [], + 'count' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Polygon aggregates. + * + * @param string $ticker Stock ticker symbol + * @param int $multiplier Size of the timespan multiplier + * @param string $timespan Size of the time window (minute, hour, day, week, month, quarter, year) + * @param string $from Start of the aggregate time window + * @param string $to End of the aggregate time window + * @param bool $adjusted Whether or not the results are adjusted for splits + * @param string $sort Sort the results by timestamp (asc, desc) + * @param int $limit Limits the number of base aggregates queried to create the aggregate results + * + * @return array{ + * success: bool, + * aggregates: array, + * status: string, + * requestId: string, + * count: int, + * }>, + * error: string, + * } + */ + public function getAggregates( + string $ticker, + int $multiplier, + string $timespan, + string $from, + string $to, + bool $adjusted = true, + string $sort = 'asc', + int $limit = 50000, + ): array { + try { + $params = [ + 'apikey' => $this->apiKey, + 'ticker' => $ticker, + 'multiplier' => $multiplier, + 'timespan' => $timespan, + 'from' => $from, + 'to' => $to, + 'adjusted' => $adjusted ? 'true' : 'false', + 'sort' => $sort, + 'limit' => max(1, min($limit, 50000)), + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/v2/aggs/ticker/{$ticker}/range/{$multiplier}/{$timespan}/{$from}/{$to}", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'aggregates' => [ + [ + 'ticker' => $data['ticker'] ?? $ticker, + 'queryCount' => $data['queryCount'] ?? 0, + 'resultsCount' => $data['resultsCount'] ?? 0, + 'adjusted' => $data['adjusted'] ?? $adjusted, + 'results' => array_map(fn ($result) => [ + 'v' => $result['v'] ?? 0.0, + 'vw' => $result['vw'] ?? 0.0, + 'o' => $result['o'] ?? 0.0, + 'c' => $result['c'] ?? 0.0, + 'h' => $result['h'] ?? 0.0, + 'l' => $result['l'] ?? 0.0, + 't' => $result['t'] ?? 0, + 'n' => $result['n'] ?? 0, + ], $data['results'] ?? []), + 'status' => $data['status'] ?? '', + 'requestId' => $data['request_id'] ?? '', + 'count' => \count($data['results'] ?? []), + ], + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'aggregates' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Polygon grouped daily data. + * + * @param string $date Date (YYYY-MM-DD) + * @param bool $adjusted Whether or not the results are adjusted for splits + * + * @return array{ + * success: bool, + * groupedDaily: array{ + * queryCount: int, + * resultsCount: int, + * adjusted: bool, + * results: array, + * status: string, + * requestId: string, + * count: int, + * }, + * error: string, + * } + */ + public function getGroupedDaily( + string $date, + bool $adjusted = true, + ): array { + try { + $params = [ + 'apikey' => $this->apiKey, + 'date' => $date, + 'adjusted' => $adjusted ? 'true' : 'false', + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/v2/aggs/grouped/locale/us/market/stocks/{$date}", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'groupedDaily' => [ + 'queryCount' => $data['queryCount'] ?? 0, + 'resultsCount' => $data['resultsCount'] ?? 0, + 'adjusted' => $data['adjusted'] ?? $adjusted, + 'results' => array_map(fn ($result) => [ + 'T' => $result['T'] ?? '', + 'v' => $result['v'] ?? 0.0, + 'vw' => $result['vw'] ?? 0.0, + 'o' => $result['o'] ?? 0.0, + 'c' => $result['c'] ?? 0.0, + 'h' => $result['h'] ?? 0.0, + 'l' => $result['l'] ?? 0.0, + 't' => $result['t'] ?? 0, + 'n' => $result['n'] ?? 0, + ], $data['results'] ?? []), + 'status' => $data['status'] ?? '', + 'requestId' => $data['request_id'] ?? '', + 'count' => \count($data['results'] ?? []), + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'groupedDaily' => [ + 'queryCount' => 0, + 'resultsCount' => 0, + 'adjusted' => $adjusted, + 'results' => [], + 'status' => '', + 'requestId' => '', + 'count' => 0, + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Polygon market status. + * + * @return array{ + * success: bool, + * market: string, + * serverTime: string, + * exchanges: array, + * currencies: array, + * error: string, + * } + */ + public function getMarketStatus(): array + { + try { + $response = $this->httpClient->request('GET', "{$this->baseUrl}/v1/marketstatus/now", [ + 'query' => array_merge($this->options, ['apikey' => $this->apiKey]), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'market' => $data['market'] ?? '', + 'serverTime' => $data['serverTime'] ?? '', + 'exchanges' => $data['exchanges'] ?? [], + 'currencies' => $data['currencies'] ?? [], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'market' => '', + 'serverTime' => '', + 'exchanges' => [], + 'currencies' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Polygon exchanges. + * + * @return array{ + * success: bool, + * exchanges: array, + * error: string, + * } + */ + public function getExchanges(): array + { + try { + $response = $this->httpClient->request('GET', "{$this->baseUrl}/v3/reference/exchanges", [ + 'query' => array_merge($this->options, ['apikey' => $this->apiKey]), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'exchanges' => array_map(fn ($exchange) => [ + 'id' => $exchange['id'] ?? '', + 'type' => $exchange['type'] ?? '', + 'market' => $exchange['market'] ?? '', + 'mic' => $exchange['mic'] ?? '', + 'name' => $exchange['name'] ?? '', + 'tape' => $exchange['tape'] ?? '', + ], $data['results'] ?? []), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'exchanges' => [], + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/PowerBi.php b/src/agent/src/Toolbox/Tool/PowerBi.php new file mode 100644 index 000000000..217a7fab8 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/PowerBi.php @@ -0,0 +1,784 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('powerbi_get_workspaces', 'Tool that gets Power BI workspaces')] +#[AsTool('powerbi_get_datasets', 'Tool that gets Power BI datasets', method: 'getDatasets')] +#[AsTool('powerbi_get_reports', 'Tool that gets Power BI reports', method: 'getReports')] +#[AsTool('powerbi_get_dashboards', 'Tool that gets Power BI dashboards', method: 'getDashboards')] +#[AsTool('powerbi_refresh_dataset', 'Tool that refreshes Power BI datasets', method: 'refreshDataset')] +#[AsTool('powerbi_create_dataset', 'Tool that creates Power BI datasets', method: 'createDataset')] +#[AsTool('powerbi_execute_query', 'Tool that executes Power BI queries', method: 'executeQuery')] +#[AsTool('powerbi_get_embed_token', 'Tool that gets Power BI embed tokens', method: 'getEmbedToken')] +final readonly class PowerBi +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $accessToken, + private string $baseUrl = 'https://api.powerbi.com/v1.0/myorg', + private array $options = [], + ) { + } + + /** + * Get Power BI workspaces. + * + * @param string $filter OData filter expression + * @param int $top Number of workspaces to return + * @param int $skip Number of workspaces to skip + * + * @return array{ + * success: bool, + * workspaces: array, + * reports: array, + * dashboards: array, + * datasets: array, + * }>, + * error: string, + * } + */ + public function __invoke( + string $filter = '', + int $top = 100, + int $skip = 0, + ): array { + try { + $params = [ + '$top' => max(1, min($top, 5000)), + '$skip' => max(0, $skip), + ]; + + if ($filter) { + $params['$filter'] = $filter; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/groups", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'workspaces' => array_map(fn ($workspace) => [ + 'id' => $workspace['id'], + 'name' => $workspace['name'], + 'isReadOnly' => $workspace['isReadOnly'] ?? false, + 'isOnDedicatedCapacity' => $workspace['isOnDedicatedCapacity'] ?? false, + 'capacityId' => $workspace['capacityId'] ?? '', + 'description' => $workspace['description'] ?? '', + 'type' => $workspace['type'] ?? '', + 'state' => $workspace['state'] ?? '', + 'isOrphaned' => $workspace['isOrphaned'] ?? false, + 'users' => array_map(fn ($user) => [ + 'displayName' => $user['displayName'], + 'emailAddress' => $user['emailAddress'], + 'identifier' => $user['identifier'], + 'graphId' => $user['graphId'], + 'principalType' => $user['principalType'], + 'accessRight' => $user['accessRight'], + ], $workspace['users'] ?? []), + 'reports' => array_map(fn ($report) => [ + 'id' => $report['id'], + 'name' => $report['name'], + 'webUrl' => $report['webUrl'], + 'embedUrl' => $report['embedUrl'], + 'datasetId' => $report['datasetId'], + 'reportType' => $report['reportType'], + ], $workspace['reports'] ?? []), + 'dashboards' => array_map(fn ($dashboard) => [ + 'id' => $dashboard['id'], + 'displayName' => $dashboard['displayName'], + 'webUrl' => $dashboard['webUrl'], + 'embedUrl' => $dashboard['embedUrl'], + 'isReadOnly' => $dashboard['isReadOnly'] ?? false, + ], $workspace['dashboards'] ?? []), + 'datasets' => array_map(fn ($dataset) => [ + 'id' => $dataset['id'], + 'name' => $dataset['name'], + 'webUrl' => $dataset['webUrl'], + 'isRefreshable' => $dataset['isRefreshable'] ?? false, + 'isEffectiveIdentityRequired' => $dataset['isEffectiveIdentityRequired'] ?? false, + 'isEffectiveIdentityRolesRequired' => $dataset['isEffectiveIdentityRolesRequired'] ?? false, + 'isOnPremGatewayRequired' => $dataset['isOnPremGatewayRequired'] ?? false, + 'targetStorageMode' => $dataset['targetStorageMode'] ?? '', + 'createReportEmbedURL' => $dataset['createReportEmbedURL'] ?? '', + 'qnaEmbedURL' => $dataset['qnaEmbedURL'] ?? '', + 'addRowsAPIEnabled' => $dataset['addRowsAPIEnabled'] ?? false, + 'configuredBy' => $dataset['configuredBy'] ?? '', + ], $workspace['datasets'] ?? []), + ], $data['value'] ?? []), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'workspaces' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Power BI datasets. + * + * @param string $groupId Workspace ID + * @param string $filter OData filter expression + * @param int $top Number of datasets to return + * @param int $skip Number of datasets to skip + * + * @return array{ + * success: bool, + * datasets: array, + * measures: array, + * }>, + * }>, + * error: string, + * } + */ + public function getDatasets( + string $groupId = '', + string $filter = '', + int $top = 100, + int $skip = 0, + ): array { + try { + $params = [ + '$top' => max(1, min($top, 5000)), + '$skip' => max(0, $skip), + ]; + + if ($filter) { + $params['$filter'] = $filter; + } + + $url = $groupId ? "{$this->baseUrl}/groups/{$groupId}/datasets" : "{$this->baseUrl}/datasets"; + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'datasets' => array_map(fn ($dataset) => [ + 'id' => $dataset['id'], + 'name' => $dataset['name'], + 'webUrl' => $dataset['webUrl'], + 'isRefreshable' => $dataset['isRefreshable'] ?? false, + 'isEffectiveIdentityRequired' => $dataset['isEffectiveIdentityRequired'] ?? false, + 'isEffectiveIdentityRolesRequired' => $dataset['isEffectiveIdentityRolesRequired'] ?? false, + 'isOnPremGatewayRequired' => $dataset['isOnPremGatewayRequired'] ?? false, + 'targetStorageMode' => $dataset['targetStorageMode'] ?? '', + 'createReportEmbedURL' => $dataset['createReportEmbedURL'] ?? '', + 'qnaEmbedURL' => $dataset['qnaEmbedURL'] ?? '', + 'addRowsAPIEnabled' => $dataset['addRowsAPIEnabled'] ?? false, + 'configuredBy' => $dataset['configuredBy'] ?? '', + 'defaultRetentionPolicy' => $dataset['defaultRetentionPolicy'] ?? '', + 'tables' => array_map(fn ($table) => [ + 'name' => $table['name'], + 'columns' => array_map(fn ($column) => [ + 'name' => $column['name'], + 'dataType' => $column['dataType'], + 'columnType' => $column['columnType'] ?? '', + 'formatString' => $column['formatString'] ?? '', + 'isHidden' => $column['isHidden'] ?? false, + ], $table['columns'] ?? []), + 'measures' => array_map(fn ($measure) => [ + 'name' => $measure['name'], + 'expression' => $measure['expression'], + 'formatString' => $measure['formatString'] ?? '', + ], $table['measures'] ?? []), + ], $dataset['tables'] ?? []), + ], $data['value'] ?? []), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'datasets' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Power BI reports. + * + * @param string $groupId Workspace ID + * @param string $filter OData filter expression + * @param int $top Number of reports to return + * @param int $skip Number of reports to skip + * + * @return array{ + * success: bool, + * reports: array, + * error: string, + * } + */ + public function getReports( + string $groupId = '', + string $filter = '', + int $top = 100, + int $skip = 0, + ): array { + try { + $params = [ + '$top' => max(1, min($top, 5000)), + '$skip' => max(0, $skip), + ]; + + if ($filter) { + $params['$filter'] = $filter; + } + + $url = $groupId ? "{$this->baseUrl}/groups/{$groupId}/reports" : "{$this->baseUrl}/reports"; + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'reports' => array_map(fn ($report) => [ + 'id' => $report['id'], + 'name' => $report['name'], + 'webUrl' => $report['webUrl'], + 'embedUrl' => $report['embedUrl'], + 'datasetId' => $report['datasetId'], + 'reportType' => $report['reportType'], + 'isOwnedByMe' => $report['isOwnedByMe'] ?? false, + 'isPublished' => $report['isPublished'] ?? false, + 'appId' => $report['appId'] ?? '', + 'description' => $report['description'] ?? '', + 'modifiedBy' => $report['modifiedBy'] ?? '', + 'modifiedDateTime' => $report['modifiedDateTime'] ?? '', + 'createdBy' => $report['createdBy'] ?? '', + 'createdDateTime' => $report['createdDateTime'] ?? '', + ], $data['value'] ?? []), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'reports' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Power BI dashboards. + * + * @param string $groupId Workspace ID + * @param string $filter OData filter expression + * @param int $top Number of dashboards to return + * @param int $skip Number of dashboards to skip + * + * @return array{ + * success: bool, + * dashboards: array, + * tiles: array, + * }>, + * error: string, + * } + */ + public function getDashboards( + string $groupId = '', + string $filter = '', + int $top = 100, + int $skip = 0, + ): array { + try { + $params = [ + '$top' => max(1, min($top, 5000)), + '$skip' => max(0, $skip), + ]; + + if ($filter) { + $params['$filter'] = $filter; + } + + $url = $groupId ? "{$this->baseUrl}/groups/{$groupId}/dashboards" : "{$this->baseUrl}/dashboards"; + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'dashboards' => array_map(fn ($dashboard) => [ + 'id' => $dashboard['id'], + 'displayName' => $dashboard['displayName'], + 'webUrl' => $dashboard['webUrl'], + 'embedUrl' => $dashboard['embedUrl'], + 'isReadOnly' => $dashboard['isReadOnly'] ?? false, + 'isOnDedicatedCapacity' => $dashboard['isOnDedicatedCapacity'] ?? false, + 'capacityId' => $dashboard['capacityId'] ?? '', + 'appId' => $dashboard['appId'] ?? '', + 'description' => $dashboard['description'] ?? '', + 'modifiedBy' => $dashboard['modifiedBy'] ?? '', + 'modifiedDateTime' => $dashboard['modifiedDateTime'] ?? '', + 'createdBy' => $dashboard['createdBy'] ?? '', + 'createdDateTime' => $dashboard['createdDateTime'] ?? '', + 'users' => array_map(fn ($user) => [ + 'displayName' => $user['displayName'], + 'emailAddress' => $user['emailAddress'], + 'identifier' => $user['identifier'], + 'graphId' => $user['graphId'], + 'principalType' => $user['principalType'], + 'accessRight' => $user['accessRight'], + ], $dashboard['users'] ?? []), + 'tiles' => array_map(fn ($tile) => [ + 'id' => $tile['id'], + 'title' => $tile['title'], + 'rowSpan' => $tile['rowSpan'], + 'colSpan' => $tile['colSpan'], + 'embedUrl' => $tile['embedUrl'], + 'embedData' => $tile['embedData'] ?? '', + 'reportId' => $tile['reportId'] ?? '', + 'datasetId' => $tile['datasetId'] ?? '', + ], $dashboard['tiles'] ?? []), + ], $data['value'] ?? []), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'dashboards' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Refresh Power BI dataset. + * + * @param string $datasetId Dataset ID + * @param string $groupId Workspace ID + * @param string $notifyOption Notification option (MailOnFailure, MailOnCompletion, NoNotification) + * + * @return array{ + * success: bool, + * refreshId: string, + * requestId: string, + * message: string, + * error: string, + * } + */ + public function refreshDataset( + string $datasetId, + string $groupId = '', + string $notifyOption = 'NoNotification', + ): array { + try { + $requestData = [ + 'notifyOption' => $notifyOption, + ]; + + $url = $groupId ? "{$this->baseUrl}/groups/{$groupId}/datasets/{$datasetId}/refreshes" : "{$this->baseUrl}/datasets/{$datasetId}/refreshes"; + + $response = $this->httpClient->request('POST', $url, [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'refreshId' => $data['id'] ?? '', + 'requestId' => $data['requestId'] ?? '', + 'message' => 'Dataset refresh initiated successfully', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'refreshId' => '', + 'requestId' => '', + 'message' => 'Failed to initiate dataset refresh', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create Power BI dataset. + * + * @param string $name Dataset name + * @param array, + * measures: array, + * }> $tables Dataset tables + * @param string $groupId Workspace ID + * @param string $defaultMode Default mode (Push, Streaming, AsOnPrem) + * + * @return array{ + * success: bool, + * datasetId: string, + * name: string, + * webUrl: string, + * isRefreshable: bool, + * addRowsAPIEnabled: bool, + * configuredBy: string, + * message: string, + * error: string, + * } + */ + public function createDataset( + string $name, + array $tables, + string $groupId = '', + string $defaultMode = 'Push', + ): array { + try { + $requestData = [ + 'name' => $name, + 'tables' => $tables, + 'defaultMode' => $defaultMode, + ]; + + $url = $groupId ? "{$this->baseUrl}/groups/{$groupId}/datasets" : "{$this->baseUrl}/datasets"; + + $response = $this->httpClient->request('POST', $url, [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'datasetId' => $data['id'], + 'name' => $data['name'], + 'webUrl' => $data['webUrl'], + 'isRefreshable' => $data['isRefreshable'] ?? false, + 'addRowsAPIEnabled' => $data['addRowsAPIEnabled'] ?? false, + 'configuredBy' => $data['configuredBy'] ?? '', + 'message' => 'Dataset created successfully', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'datasetId' => '', + 'name' => $name, + 'webUrl' => '', + 'isRefreshable' => false, + 'addRowsAPIEnabled' => false, + 'configuredBy' => '', + 'message' => 'Failed to create dataset', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Execute Power BI query. + * + * @param string $datasetId Dataset ID + * @param string $query DAX query + * @param string $groupId Workspace ID + * + * @return array{ + * success: bool, + * results: array>, + * columns: array, + * error: string, + * } + */ + public function executeQuery( + string $datasetId, + string $query, + string $groupId = '', + ): array { + try { + $requestData = [ + 'query' => $query, + ]; + + $url = $groupId ? "{$this->baseUrl}/groups/{$groupId}/datasets/{$datasetId}/executeQueries" : "{$this->baseUrl}/datasets/{$datasetId}/executeQueries"; + + $response = $this->httpClient->request('POST', $url, [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'results' => $data['results'] ?? [], + 'columns' => array_map(fn ($column) => [ + 'name' => $column['name'], + 'dataType' => $column['dataType'], + ], $data['columns'] ?? []), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'results' => [], + 'columns' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Power BI embed token. + * + * @param string $reportId Report ID + * @param string $datasetId Dataset ID + * @param string $groupId Workspace ID + * @param array $identities User identities + * @param array $effectiveIdentity Effective identity + * + * @return array{ + * success: bool, + * token: string, + * tokenId: string, + * expiration: string, + * accessLevel: string, + * error: string, + * } + */ + public function getEmbedToken( + string $reportId, + string $datasetId, + string $groupId = '', + array $identities = [], + array $effectiveIdentity = [], + ): array { + try { + $requestData = [ + 'reports' => [ + [ + 'reportId' => $reportId, + 'datasetId' => $datasetId, + ], + ], + ]; + + if (!empty($identities)) { + $requestData['identities'] = $identities; + } + + if (!empty($effectiveIdentity)) { + $requestData['effectiveIdentity'] = $effectiveIdentity; + } + + $url = $groupId ? "{$this->baseUrl}/groups/{$groupId}/reports/{$reportId}/GenerateToken" : "{$this->baseUrl}/reports/{$reportId}/GenerateToken"; + + $response = $this->httpClient->request('POST', $url, [ + 'headers' => [ + 'Authorization' => "Bearer {$this->accessToken}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'token' => $data['token'] ?? '', + 'tokenId' => $data['tokenId'] ?? '', + 'expiration' => $data['expiration'] ?? '', + 'accessLevel' => $data['accessLevel'] ?? '', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'token' => '', + 'tokenId' => '', + 'expiration' => '', + 'accessLevel' => '', + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Prometheus.php b/src/agent/src/Toolbox/Tool/Prometheus.php new file mode 100644 index 000000000..1e36a1fe5 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Prometheus.php @@ -0,0 +1,412 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('prometheus_query', 'Tool that queries Prometheus metrics')] +#[AsTool('prometheus_query_range', 'Tool that queries Prometheus metrics over time range', method: 'queryRange')] +#[AsTool('prometheus_get_series', 'Tool that gets Prometheus series', method: 'getSeries')] +#[AsTool('prometheus_get_labels', 'Tool that gets Prometheus labels', method: 'getLabels')] +#[AsTool('prometheus_get_targets', 'Tool that gets Prometheus targets', method: 'getTargets')] +#[AsTool('prometheus_get_alerts', 'Tool that gets Prometheus alerts', method: 'getAlerts')] +final readonly class Prometheus +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $baseUrl, + private string $apiVersion = 'v1', + private array $options = [], + ) { + } + + /** + * Query Prometheus metrics. + * + * @param string $query PromQL query + * @param string $time Evaluation timestamp (Unix timestamp or RFC3339) + * @param string $timeout Query timeout (e.g., 5s, 1m) + * + * @return array{ + * status: string, + * data: array{ + * resultType: string, + * result: array, + * value: array{0: int, 1: string}|null, + * values: array|null, + * }>, + * }, + * }|string + */ + public function __invoke( + string $query, + string $time = '', + string $timeout = '5s', + ): array|string { + try { + $params = [ + 'query' => $query, + 'timeout' => $timeout, + ]; + + if ($time) { + $params['time'] = $time; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->apiKey) { + $headers['Authorization'] = 'Bearer '.$this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/{$this->apiVersion}/query", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error querying Prometheus: '.($data['error'] ?? 'Unknown error'); + } + + return [ + 'status' => $data['status'], + 'data' => [ + 'resultType' => $data['data']['resultType'], + 'result' => array_map(fn ($result) => [ + 'metric' => $result['metric'], + 'value' => $result['value'] ?? null, + 'values' => $result['values'] ?? null, + ], $data['data']['result'] ?? []), + ], + ]; + } catch (\Exception $e) { + return 'Error querying Prometheus: '.$e->getMessage(); + } + } + + /** + * Query Prometheus metrics over time range. + * + * @param string $query PromQL query + * @param string $start Start time (Unix timestamp or RFC3339) + * @param string $end End time (Unix timestamp or RFC3339) + * @param string $step Query resolution step width (e.g., 15s, 1m, 5m) + * @param string $timeout Query timeout (e.g., 5s, 1m) + * + * @return array{ + * status: string, + * data: array{ + * resultType: string, + * result: array, + * values: array, + * }>, + * }, + * }|string + */ + public function queryRange( + string $query, + string $start, + string $end, + string $step, + string $timeout = '5s', + ): array|string { + try { + $params = [ + 'query' => $query, + 'start' => $start, + 'end' => $end, + 'step' => $step, + 'timeout' => $timeout, + ]; + + $headers = ['Content-Type' => 'application/json']; + if ($this->apiKey) { + $headers['Authorization'] = 'Bearer '.$this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/{$this->apiVersion}/query_range", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error querying Prometheus range: '.($data['error'] ?? 'Unknown error'); + } + + return [ + 'status' => $data['status'], + 'data' => [ + 'resultType' => $data['data']['resultType'], + 'result' => array_map(fn ($result) => [ + 'metric' => $result['metric'], + 'values' => $result['values'] ?? [], + ], $data['data']['result'] ?? []), + ], + ]; + } catch (\Exception $e) { + return 'Error querying Prometheus range: '.$e->getMessage(); + } + } + + /** + * Get Prometheus series. + * + * @param string $match Series selector (e.g., up, {job="prometheus"}) + * @param string $start Start time (Unix timestamp or RFC3339) + * @param string $end End time (Unix timestamp or RFC3339) + * + * @return array{ + * status: string, + * data: array>, + * }|string + */ + public function getSeries( + string $match, + string $start = '', + string $end = '', + ): array|string { + try { + $params = [ + 'match[]' => $match, + ]; + + if ($start) { + $params['start'] = $start; + } + if ($end) { + $params['end'] = $end; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->apiKey) { + $headers['Authorization'] = 'Bearer '.$this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/{$this->apiVersion}/series", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting Prometheus series: '.($data['error'] ?? 'Unknown error'); + } + + return [ + 'status' => $data['status'], + 'data' => array_map(fn ($series) => $series, $data['data'] ?? []), + ]; + } catch (\Exception $e) { + return 'Error getting Prometheus series: '.$e->getMessage(); + } + } + + /** + * Get Prometheus labels. + * + * @param string $match Series selector (optional) + * @param string $start Start time (Unix timestamp or RFC3339) + * @param string $end End time (Unix timestamp or RFC3339) + * + * @return array{ + * status: string, + * data: array, + * }|string + */ + public function getLabels( + string $match = '', + string $start = '', + string $end = '', + ): array|string { + try { + $params = []; + + if ($match) { + $params['match[]'] = $match; + } + if ($start) { + $params['start'] = $start; + } + if ($end) { + $params['end'] = $end; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->apiKey) { + $headers['Authorization'] = 'Bearer '.$this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/{$this->apiVersion}/labels", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting Prometheus labels: '.($data['error'] ?? 'Unknown error'); + } + + return [ + 'status' => $data['status'], + 'data' => $data['data'] ?? [], + ]; + } catch (\Exception $e) { + return 'Error getting Prometheus labels: '.$e->getMessage(); + } + } + + /** + * Get Prometheus targets. + * + * @param string $state Target state filter (active, dropped) + * + * @return array{ + * status: string, + * data: array{ + * activeTargets: array, + * labels: array, + * scrapePool: string, + * scrapeUrl: string, + * globalUrl: string, + * lastError: string, + * lastScrape: string, + * lastScrapeDuration: float, + * health: string, + * scrapeInterval: string, + * scrapeTimeout: string, + * }>, + * droppedTargets: array, + * }>, + * }, + * }|string + */ + public function getTargets(string $state = ''): array|string + { + try { + $params = []; + + if ($state) { + $params['state'] = $state; + } + + $headers = ['Content-Type' => 'application/json']; + if ($this->apiKey) { + $headers['Authorization'] = 'Bearer '.$this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/{$this->apiVersion}/targets", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting Prometheus targets: '.($data['error'] ?? 'Unknown error'); + } + + return [ + 'status' => $data['status'], + 'data' => [ + 'activeTargets' => array_map(fn ($target) => [ + 'discoveredLabels' => $target['discoveredLabels'] ?? [], + 'labels' => $target['labels'] ?? [], + 'scrapePool' => $target['scrapePool'] ?? '', + 'scrapeUrl' => $target['scrapeUrl'] ?? '', + 'globalUrl' => $target['globalUrl'] ?? '', + 'lastError' => $target['lastError'] ?? '', + 'lastScrape' => $target['lastScrape'] ?? '', + 'lastScrapeDuration' => $target['lastScrapeDuration'] ?? 0.0, + 'health' => $target['health'] ?? 'unknown', + 'scrapeInterval' => $target['scrapeInterval'] ?? '', + 'scrapeTimeout' => $target['scrapeTimeout'] ?? '', + ], $data['data']['activeTargets'] ?? []), + 'droppedTargets' => array_map(fn ($target) => [ + 'discoveredLabels' => $target['discoveredLabels'] ?? [], + ], $data['data']['droppedTargets'] ?? []), + ], + ]; + } catch (\Exception $e) { + return 'Error getting Prometheus targets: '.$e->getMessage(); + } + } + + /** + * Get Prometheus alerts. + * + * @return array{ + * status: string, + * data: array{ + * alerts: array, + * annotations: array, + * state: string, + * activeAt: string, + * value: string, + * partialFingerprint: string, + * }>, + * }, + * }|string + */ + public function getAlerts(): array|string + { + try { + $headers = ['Content-Type' => 'application/json']; + if ($this->apiKey) { + $headers['Authorization'] = 'Bearer '.$this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/api/{$this->apiVersion}/alerts", [ + 'headers' => $headers, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting Prometheus alerts: '.($data['error'] ?? 'Unknown error'); + } + + return [ + 'status' => $data['status'], + 'data' => [ + 'alerts' => array_map(fn ($alert) => [ + 'labels' => $alert['labels'] ?? [], + 'annotations' => $alert['annotations'] ?? [], + 'state' => $alert['state'] ?? 'unknown', + 'activeAt' => $alert['activeAt'] ?? '', + 'value' => $alert['value'] ?? '', + 'partialFingerprint' => $alert['partialFingerprint'] ?? '', + ], $data['data']['alerts'] ?? []), + ], + ]; + } catch (\Exception $e) { + return 'Error getting Prometheus alerts: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/PubMed.php b/src/agent/src/Toolbox/Tool/PubMed.php new file mode 100644 index 000000000..62a9d02e9 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/PubMed.php @@ -0,0 +1,363 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('pub_med', 'Tool that searches the PubMed API for medical literature')] +#[AsTool('pub_med_abstract', 'Tool that gets detailed abstracts from PubMed', method: 'getAbstract')] +final readonly class PubMed +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private array $options = [], + ) { + } + + /** + * Search PubMed for medical literature. + * + * @param string $query search query for medical literature + * @param int $maxResults The maximum number of results to return + * @param string $sort Sort order (relevance, pub_date, first_author, journal, title) + * + * @return array, + * }> + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + int $maxResults = 10, + string $sort = 'relevance', + ): array { + try { + // Search PubMed + $searchResponse = $this->httpClient->request('GET', 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi', [ + 'query' => array_merge($this->options, [ + 'db' => 'pubmed', + 'term' => $query, + 'retmax' => $maxResults, + 'retmode' => 'json', + 'sort' => $sort, + ]), + ]); + + $searchData = $searchResponse->toArray(); + $pmids = $searchData['esearchresult']['idlist'] ?? []; + + if (empty($pmids)) { + return []; + } + + // Get detailed information for each PMID + $detailsResponse = $this->httpClient->request('GET', 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi', [ + 'query' => [ + 'db' => 'pubmed', + 'id' => implode(',', $pmids), + 'retmode' => 'xml', + 'rettype' => 'abstract', + ], + ]); + + $xml = $detailsResponse->getContent(); + + return $this->parsePubMedXml($xml); + } catch (\Exception $e) { + return [ + [ + 'pmid' => 'error', + 'title' => 'Search Error', + 'authors' => '', + 'journal' => '', + 'publication_date' => '', + 'abstract' => 'Unable to search PubMed: '.$e->getMessage(), + 'doi' => '', + 'mesh_terms' => [], + ], + ]; + } + } + + /** + * Get detailed abstract for a specific PMID. + * + * @param string $pmid PubMed ID + * + * @return array{ + * pmid: string, + * title: string, + * authors: string, + * journal: string, + * publication_date: string, + * abstract: string, + * doi: string, + * mesh_terms: array, + * keywords: array, + * references: int, + * }|null + */ + public function getAbstract(string $pmid): ?array + { + try { + $response = $this->httpClient->request('GET', 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi', [ + 'query' => [ + 'db' => 'pubmed', + 'id' => $pmid, + 'retmode' => 'xml', + 'rettype' => 'abstract', + ], + ]); + + $xml = $response->getContent(); + $results = $this->parsePubMedXml($xml); + + if (empty($results)) { + return null; + } + + $result = $results[0]; + + // Add additional details + $result['keywords'] = $this->extractKeywords($xml); + $result['references'] = $this->countReferences($xml); + + return $result; + } catch (\Exception $e) { + return null; + } + } + + /** + * Parse PubMed XML response. + * + * @return array, + * }> + */ + private function parsePubMedXml(string $xml): array + { + try { + $dom = new \DOMDocument(); + $dom->loadXML($xml); + + $articles = $dom->getElementsByTagName('PubmedArticle'); + $results = []; + + foreach ($articles as $article) { + $pmid = $this->getElementText($article, 'PMID'); + $title = $this->getElementText($article, 'ArticleTitle'); + $journal = $this->getElementText($article, 'Title'); // Journal title + $abstract = $this->getElementText($article, 'AbstractText'); + $doi = $this->extractDoi($article); + $authors = $this->extractAuthors($article); + $publicationDate = $this->extractPublicationDate($article); + $meshTerms = $this->extractMeshTerms($article); + + $results[] = [ + 'pmid' => $pmid, + 'title' => $title, + 'authors' => $authors, + 'journal' => $journal, + 'publication_date' => $publicationDate, + 'abstract' => $abstract, + 'doi' => $doi, + 'mesh_terms' => $meshTerms, + ]; + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'pmid' => 'parse_error', + 'title' => 'Parse Error', + 'authors' => '', + 'journal' => '', + 'publication_date' => '', + 'abstract' => 'Unable to parse PubMed response: '.$e->getMessage(), + 'doi' => '', + 'mesh_terms' => [], + ], + ]; + } + } + + /** + * Get text content of an element. + */ + private function getElementText(\DOMElement $parent, string $tagName): string + { + $elements = $parent->getElementsByTagName($tagName); + if ($elements->length > 0) { + return $elements->item(0)->textContent ?? ''; + } + + return ''; + } + + /** + * Extract DOI from article. + */ + private function extractDoi(\DOMElement $article): string + { + $elinkElements = $article->getElementsByTagName('ELocationID'); + foreach ($elinkElements as $element) { + $type = $element->getAttribute('EIdType'); + if ('doi' === $type) { + return $element->textContent ?? ''; + } + } + + return ''; + } + + /** + * Extract authors from article. + */ + private function extractAuthors(\DOMElement $article): string + { + $authorList = $article->getElementsByTagName('AuthorList'); + if (0 === $authorList->length) { + return ''; + } + + $authors = []; + $authorElements = $authorList->item(0)->getElementsByTagName('Author'); + + foreach ($authorElements as $author) { + $lastName = $this->getElementText($author, 'LastName'); + $firstName = $this->getElementText($author, 'ForeName'); + + if ($lastName) { + $authorName = $lastName; + if ($firstName) { + $authorName .= ', '.$firstName; + } + $authors[] = $authorName; + } + } + + return implode('; ', $authors); + } + + /** + * Extract publication date. + */ + private function extractPublicationDate(\DOMElement $article): string + { + $pubDate = $article->getElementsByTagName('PubDate'); + if (0 === $pubDate->length) { + return ''; + } + + $year = $this->getElementText($pubDate->item(0), 'Year'); + $month = $this->getElementText($pubDate->item(0), 'Month'); + $day = $this->getElementText($pubDate->item(0), 'Day'); + + $date = $year; + if ($month) { + $date .= '-'.$month; + } + if ($day) { + $date .= '-'.$day; + } + + return $date; + } + + /** + * Extract MeSH terms. + * + * @return array + */ + private function extractMeshTerms(\DOMElement $article): array + { + $meshHeadings = $article->getElementsByTagName('MeshHeading'); + $terms = []; + + foreach ($meshHeadings as $heading) { + $descriptor = $this->getElementText($heading, 'DescriptorName'); + if ($descriptor) { + $terms[] = $descriptor; + } + } + + return $terms; + } + + /** + * Extract keywords from article. + * + * @return array + */ + private function extractKeywords(string $xml): array + { + try { + $dom = new \DOMDocument(); + $dom->loadXML($xml); + + $keywords = []; + $keywordElements = $dom->getElementsByTagName('Keyword'); + + foreach ($keywordElements as $keyword) { + $text = $keyword->textContent ?? ''; + if ($text) { + $keywords[] = $text; + } + } + + return $keywords; + } catch (\Exception $e) { + return []; + } + } + + /** + * Count references in article. + */ + private function countReferences(string $xml): int + { + try { + $dom = new \DOMDocument(); + $dom->loadXML($xml); + + $refElements = $dom->getElementsByTagName('Reference'); + + return $refElements->length; + } catch (\Exception $e) { + return 0; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/RedditSearch.php b/src/agent/src/Toolbox/Tool/RedditSearch.php new file mode 100644 index 000000000..0d807c72a --- /dev/null +++ b/src/agent/src/Toolbox/Tool/RedditSearch.php @@ -0,0 +1,196 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('reddit_search', 'Tool that searches for posts on Reddit')] +final readonly class RedditSearch +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private ?string $clientId = null, + #[\SensitiveParameter] private ?string $clientSecret = null, + #[\SensitiveParameter] private ?string $userAgent = null, + private array $options = [], + ) { + } + + /** + * Search for posts on Reddit. + * + * @param string $query Query string that post title should contain, or '*' if anything is allowed + * @param string $sort Sort method: "relevance", "hot", "top", "new", or "comments" + * @param string $timeFilter Time period to filter by: "all", "day", "hour", "month", "week", or "year" + * @param string $subreddit Name of subreddit, like "all" for r/all + * @param int $limit Maximum number of results to return + * + * @return array + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + string $sort = 'relevance', + string $timeFilter = 'all', + string $subreddit = 'all', + int $limit = 10, + ): array { + try { + // Use Reddit's JSON API (no authentication required for public data) + $url = $this->buildRedditUrl($query, $sort, $timeFilter, $subreddit, $limit); + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => [ + 'User-Agent' => $this->userAgent ?? 'Symfony-AI-Agent/1.0', + ], + ]); + + $data = $response->toArray(); + + if (!isset($data['data']['children'])) { + return []; + } + + $results = []; + foreach ($data['data']['children'] as $post) { + $postData = $post['data']; + + $results[] = [ + 'title' => $postData['title'] ?? '', + 'author' => $postData['author'] ?? '', + 'score' => $postData['score'] ?? 0, + 'num_comments' => $postData['num_comments'] ?? 0, + 'created_utc' => date('Y-m-d H:i:s', $postData['created_utc'] ?? 0), + 'url' => $postData['url'] ?? '', + 'permalink' => 'https://reddit.com'.($postData['permalink'] ?? ''), + 'subreddit' => $postData['subreddit'] ?? '', + 'selftext' => $postData['selftext'] ?? '', + 'is_self' => $postData['is_self'] ?? false, + 'thumbnail' => $postData['thumbnail'] ?? '', + ]; + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'title' => 'Search Error', + 'author' => '', + 'score' => 0, + 'num_comments' => 0, + 'created_utc' => '', + 'url' => '', + 'permalink' => '', + 'subreddit' => '', + 'selftext' => 'Unable to search Reddit: '.$e->getMessage(), + 'is_self' => false, + 'thumbnail' => '', + ], + ]; + } + } + + /** + * Get popular posts from a subreddit. + * + * @param string $subreddit Name of subreddit + * @param int $limit Maximum number of results to return + * + * @return array + */ + public function getPopularPosts(string $subreddit = 'all', int $limit = 10): array + { + return $this->__invoke('*', 'hot', 'day', $subreddit, $limit); + } + + /** + * Get top posts from a subreddit. + * + * @param string $subreddit Name of subreddit + * @param string $timeFilter Time period: "all", "day", "hour", "month", "week", or "year" + * @param int $limit Maximum number of results to return + * + * @return array + */ + public function getTopPosts(string $subreddit = 'all', string $timeFilter = 'week', int $limit = 10): array + { + return $this->__invoke('*', 'top', $timeFilter, $subreddit, $limit); + } + + /** + * Build Reddit API URL for search. + */ + private function buildRedditUrl(string $query, string $sort, string $timeFilter, string $subreddit, int $limit): string + { + $baseUrl = 'https://www.reddit.com/r/'.$subreddit.'/search.json'; + + $params = [ + 'q' => $query, + 'sort' => $sort, + 't' => $timeFilter, + 'limit' => $limit, + 'restrict_sr' => 'all' !== $subreddit ? 'true' : 'false', + 'raw_json' => '1', + ]; + + // Clean up query parameter + if ('*' === $query) { + unset($params['q']); + } + + return $baseUrl.'?'.http_build_query($params); + } +} diff --git a/src/agent/src/Toolbox/Tool/Render.php b/src/agent/src/Toolbox/Tool/Render.php new file mode 100644 index 000000000..f291fef4f --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Render.php @@ -0,0 +1,611 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('render_deploy_service', 'Tool that deploys services to Render')] +#[AsTool('render_get_service', 'Tool that gets Render service details', method: 'getService')] +#[AsTool('render_list_services', 'Tool that lists Render services', method: 'listServices')] +#[AsTool('render_delete_service', 'Tool that deletes Render services', method: 'deleteService')] +#[AsTool('render_get_logs', 'Tool that gets Render service logs', method: 'getLogs')] +#[AsTool('render_suspend_service', 'Tool that suspends Render services', method: 'suspendService')] +#[AsTool('render_resume_service', 'Tool that resumes Render services', method: 'resumeService')] +#[AsTool('render_get_deployments', 'Tool that gets Render deployments', method: 'getDeployments')] +final readonly class Render +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.render.com/v1', + private array $options = [], + ) { + } + + /** + * Deploy service to Render. + * + * @param string $name Service name + * @param string $repo Repository URL + * @param string $branch Branch name + * @param string $buildCommand Build command + * @param string $startCommand Start command + * @param string $serviceType Service type (web_service, static_site, private_service, background_worker, cron_job) + * @param string $runtime Runtime (node, python, ruby, go, docker, etc.) + * @param array $environmentVariables Environment variables + * @param array $envVars Environment variables (alias) + * + * @return array{ + * success: bool, + * service: array{ + * id: string, + * name: string, + * type: string, + * repo: string, + * branch: string, + * buildCommand: string, + * startCommand: string, + * runtime: string, + * status: string, + * url: string, + * createdAt: string, + * updatedAt: string, + * environmentVariables: array, + * }, + * deployment: array{ + * id: string, + * commit: string, + * status: string, + * createdAt: string, + * }, + * error: string, + * } + */ + public function __invoke( + string $name, + string $repo, + string $branch = 'main', + string $buildCommand = '', + string $startCommand = '', + string $serviceType = 'web_service', + string $runtime = 'node', + array $environmentVariables = [], + array $envVars = [], + ): array { + try { + $env = array_merge($environmentVariables, $envVars); + + $requestData = [ + 'name' => $name, + 'type' => $serviceType, + 'repo' => $repo, + 'branch' => $branch, + 'runtime' => $runtime, + 'buildCommand' => $buildCommand, + 'startCommand' => $startCommand, + 'envVars' => $env, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/services", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $service = $data['service'] ?? []; + $deployment = $data['deployment'] ?? []; + + return [ + 'success' => true, + 'service' => [ + 'id' => $service['id'] ?? '', + 'name' => $service['name'] ?? $name, + 'type' => $service['type'] ?? $serviceType, + 'repo' => $service['repo'] ?? $repo, + 'branch' => $service['branch'] ?? $branch, + 'buildCommand' => $service['buildCommand'] ?? $buildCommand, + 'startCommand' => $service['startCommand'] ?? $startCommand, + 'runtime' => $service['runtime'] ?? $runtime, + 'status' => $service['status'] ?? 'building', + 'url' => $service['url'] ?? '', + 'createdAt' => $service['createdAt'] ?? date('c'), + 'updatedAt' => $service['updatedAt'] ?? date('c'), + 'environmentVariables' => $service['envVars'] ?? $env, + ], + 'deployment' => [ + 'id' => $deployment['id'] ?? '', + 'commit' => $deployment['commit'] ?? '', + 'status' => $deployment['status'] ?? 'building', + 'createdAt' => $deployment['createdAt'] ?? date('c'), + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'service' => [ + 'id' => '', + 'name' => $name, + 'type' => $serviceType, + 'repo' => $repo, + 'branch' => $branch, + 'buildCommand' => $buildCommand, + 'startCommand' => $startCommand, + 'runtime' => $runtime, + 'status' => 'error', + 'url' => '', + 'createdAt' => '', + 'updatedAt' => '', + 'environmentVariables' => $env, + ], + 'deployment' => [ + 'id' => '', + 'commit' => '', + 'status' => 'error', + 'createdAt' => '', + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Render service details. + * + * @param string $serviceId Service ID + * + * @return array{ + * success: bool, + * service: array{ + * id: string, + * name: string, + * type: string, + * repo: string, + * branch: string, + * buildCommand: string, + * startCommand: string, + * runtime: string, + * status: string, + * url: string, + * createdAt: string, + * updatedAt: string, + * environmentVariables: array, + * lastDeploy: array{ + * id: string, + * commit: string, + * status: string, + * createdAt: string, + * }, + * }, + * error: string, + * } + */ + public function getService(string $serviceId): array + { + try { + $response = $this->httpClient->request('GET', "{$this->baseUrl}/services/{$serviceId}", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + ] + $this->options); + + $data = $response->toArray(); + $service = $data['service'] ?? []; + + return [ + 'success' => true, + 'service' => [ + 'id' => $service['id'] ?? $serviceId, + 'name' => $service['name'] ?? '', + 'type' => $service['type'] ?? '', + 'repo' => $service['repo'] ?? '', + 'branch' => $service['branch'] ?? '', + 'buildCommand' => $service['buildCommand'] ?? '', + 'startCommand' => $service['startCommand'] ?? '', + 'runtime' => $service['runtime'] ?? '', + 'status' => $service['status'] ?? '', + 'url' => $service['url'] ?? '', + 'createdAt' => $service['createdAt'] ?? '', + 'updatedAt' => $service['updatedAt'] ?? '', + 'environmentVariables' => $service['envVars'] ?? [], + 'lastDeploy' => [ + 'id' => $service['lastDeploy']['id'] ?? '', + 'commit' => $service['lastDeploy']['commit'] ?? '', + 'status' => $service['lastDeploy']['status'] ?? '', + 'createdAt' => $service['lastDeploy']['createdAt'] ?? '', + ], + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'service' => [ + 'id' => $serviceId, + 'name' => '', + 'type' => '', + 'repo' => '', + 'branch' => '', + 'buildCommand' => '', + 'startCommand' => '', + 'runtime' => '', + 'status' => 'error', + 'url' => '', + 'createdAt' => '', + 'updatedAt' => '', + 'environmentVariables' => [], + 'lastDeploy' => [ + 'id' => '', + 'commit' => '', + 'status' => '', + 'createdAt' => '', + ], + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * List Render services. + * + * @param int $limit Number of services to return + * @param int $offset Offset for pagination + * @param string $type Service type filter + * + * @return array{ + * success: bool, + * services: array, + * total: int, + * limit: int, + * offset: int, + * error: string, + * } + */ + public function listServices( + int $limit = 20, + int $offset = 0, + string $type = '', + ): array { + try { + $params = [ + 'limit' => max(1, min($limit, 100)), + 'offset' => max(0, $offset), + ]; + + if ($type) { + $params['type'] = $type; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/services", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'services' => array_map(fn ($service) => [ + 'id' => $service['id'] ?? '', + 'name' => $service['name'] ?? '', + 'type' => $service['type'] ?? '', + 'repo' => $service['repo'] ?? '', + 'branch' => $service['branch'] ?? '', + 'runtime' => $service['runtime'] ?? '', + 'status' => $service['status'] ?? '', + 'url' => $service['url'] ?? '', + 'createdAt' => $service['createdAt'] ?? '', + 'updatedAt' => $service['updatedAt'] ?? '', + ], $data['services'] ?? []), + 'total' => $data['total'] ?? 0, + 'limit' => $limit, + 'offset' => $offset, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'services' => [], + 'total' => 0, + 'limit' => $limit, + 'offset' => $offset, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Delete Render service. + * + * @param string $serviceId Service ID + * + * @return array{ + * success: bool, + * message: string, + * error: string, + * } + */ + public function deleteService(string $serviceId): array + { + try { + $response = $this->httpClient->request('DELETE', "{$this->baseUrl}/services/{$serviceId}", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + ] + $this->options); + + return [ + 'success' => true, + 'message' => 'Service deleted successfully', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'message' => 'Failed to delete service', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Render service logs. + * + * @param string $serviceId Service ID + * @param string $startTime Start time (ISO 8601 format) + * @param string $endTime End time (ISO 8601 format) + * @param int $limit Number of log entries + * + * @return array{ + * success: bool, + * logs: array, + * serviceId: string, + * total: int, + * error: string, + * } + */ + public function getLogs( + string $serviceId, + string $startTime = '', + string $endTime = '', + int $limit = 100, + ): array { + try { + $params = [ + 'limit' => max(1, min($limit, 1000)), + ]; + + if ($startTime) { + $params['startTime'] = $startTime; + } + + if ($endTime) { + $params['endTime'] = $endTime; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/services/{$serviceId}/logs", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'logs' => array_map(fn ($log) => [ + 'id' => $log['id'] ?? '', + 'message' => $log['message'] ?? '', + 'level' => $log['level'] ?? 'info', + 'timestamp' => $log['timestamp'] ?? '', + 'source' => $log['source'] ?? '', + ], $data['logs'] ?? []), + 'serviceId' => $serviceId, + 'total' => $data['total'] ?? 0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'logs' => [], + 'serviceId' => $serviceId, + 'total' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Suspend Render service. + * + * @param string $serviceId Service ID + * + * @return array{ + * success: bool, + * message: string, + * error: string, + * } + */ + public function suspendService(string $serviceId): array + { + try { + $response = $this->httpClient->request('POST', "{$this->baseUrl}/services/{$serviceId}/suspend", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + ] + $this->options); + + return [ + 'success' => true, + 'message' => 'Service suspended successfully', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'message' => 'Failed to suspend service', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Resume Render service. + * + * @param string $serviceId Service ID + * + * @return array{ + * success: bool, + * message: string, + * error: string, + * } + */ + public function resumeService(string $serviceId): array + { + try { + $response = $this->httpClient->request('POST', "{$this->baseUrl}/services/{$serviceId}/resume", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + ] + $this->options); + + return [ + 'success' => true, + 'message' => 'Service resumed successfully', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'message' => 'Failed to resume service', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get Render deployments. + * + * @param string $serviceId Service ID + * @param int $limit Number of deployments + * @param int $offset Offset for pagination + * + * @return array{ + * success: bool, + * deployments: array, + * logs: string, + * }>, + * serviceId: string, + * total: int, + * limit: int, + * offset: int, + * error: string, + * } + */ + public function getDeployments( + string $serviceId, + int $limit = 20, + int $offset = 0, + ): array { + try { + $params = [ + 'limit' => max(1, min($limit, 100)), + 'offset' => max(0, $offset), + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/services/{$serviceId}/deployments", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'deployments' => array_map(fn ($deployment) => [ + 'id' => $deployment['id'] ?? '', + 'commit' => $deployment['commit'] ?? '', + 'status' => $deployment['status'] ?? '', + 'createdAt' => $deployment['createdAt'] ?? '', + 'finishedAt' => $deployment['finishedAt'] ?? '', + 'buildCommand' => $deployment['buildCommand'] ?? '', + 'startCommand' => $deployment['startCommand'] ?? '', + 'environmentVariables' => $deployment['envVars'] ?? [], + 'logs' => $deployment['logs'] ?? '', + ], $data['deployments'] ?? []), + 'serviceId' => $serviceId, + 'total' => $data['total'] ?? 0, + 'limit' => $limit, + 'offset' => $offset, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'deployments' => [], + 'serviceId' => $serviceId, + 'total' => 0, + 'limit' => $limit, + 'offset' => $offset, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Riza.php b/src/agent/src/Toolbox/Tool/Riza.php new file mode 100644 index 000000000..b3412f54d --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Riza.php @@ -0,0 +1,967 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('riza_analyze', 'Tool that analyzes data using Riza')] +#[AsTool('riza_predict', 'Tool that makes predictions', method: 'predict')] +#[AsTool('riza_train', 'Tool that trains models', method: 'train')] +#[AsTool('riza_evaluate', 'Tool that evaluates models', method: 'evaluate')] +#[AsTool('riza_deploy', 'Tool that deploys models', method: 'deploy')] +#[AsTool('riza_optimize', 'Tool that optimizes models', method: 'optimize')] +#[AsTool('riza_visualize', 'Tool that creates visualizations', method: 'visualize')] +#[AsTool('riza_export', 'Tool that exports results', method: 'export')] +final readonly class Riza +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.riza.ai/v1', + private array $options = [], + ) { + } + + /** + * Analyze data using Riza. + * + * @param array> $data Data to analyze + * @param string $analysisType Type of analysis + * @param array $options Analysis options + * + * @return array{ + * success: bool, + * analysis: array{ + * data: array>, + * analysis_type: string, + * results: array{ + * summary: array{ + * total_records: int, + * columns: array, + * data_types: array, + * missing_values: array, + * unique_values: array, + * }, + * statistics: array, + * }>, + * correlations: array>, + * insights: array, + * recommendations: array, + * }, + * visualizations: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + array $data, + string $analysisType = 'descriptive', + array $options = [], + ): array { + try { + $requestData = [ + 'data' => $data, + 'analysis_type' => $analysisType, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/analyze", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $analysis = $responseData['analysis'] ?? []; + + return [ + 'success' => true, + 'analysis' => [ + 'data' => $data, + 'analysis_type' => $analysisType, + 'results' => [ + 'summary' => [ + 'total_records' => $analysis['results']['summary']['total_records'] ?? \count($data), + 'columns' => $analysis['results']['summary']['columns'] ?? [], + 'data_types' => $analysis['results']['summary']['data_types'] ?? [], + 'missing_values' => $analysis['results']['summary']['missing_values'] ?? [], + 'unique_values' => $analysis['results']['summary']['unique_values'] ?? [], + ], + 'statistics' => $analysis['results']['statistics'] ?? [], + 'correlations' => $analysis['results']['correlations'] ?? [], + 'insights' => $analysis['results']['insights'] ?? [], + 'recommendations' => $analysis['results']['recommendations'] ?? [], + ], + 'visualizations' => array_map(fn ($viz) => [ + 'type' => $viz['type'] ?? '', + 'title' => $viz['title'] ?? '', + 'url' => $viz['url'] ?? '', + ], $analysis['visualizations'] ?? []), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'analysis' => [ + 'data' => $data, + 'analysis_type' => $analysisType, + 'results' => [ + 'summary' => [ + 'total_records' => \count($data), + 'columns' => [], + 'data_types' => [], + 'missing_values' => [], + 'unique_values' => [], + ], + 'statistics' => [], + 'correlations' => [], + 'insights' => [], + 'recommendations' => [], + ], + 'visualizations' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Make predictions. + * + * @param array> $data Data for prediction + * @param string $modelType Model type to use + * @param array $modelParams Model parameters + * @param array $predictionOptions Prediction options + * + * @return array{ + * success: bool, + * prediction: array{ + * data: array>, + * model_type: string, + * predictions: array, + * prediction: mixed, + * confidence: float, + * probability: array, + * }>, + * model_metrics: array{ + * accuracy: float, + * precision: float, + * recall: float, + * f1_score: float, + * auc: float, + * }, + * feature_importance: array, + * prediction_interval: array{ + * lower_bound: float, + * upper_bound: float, + * confidence_level: float, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function predict( + array $data, + string $modelType = 'classification', + array $modelParams = [], + array $predictionOptions = [], + ): array { + try { + $requestData = [ + 'data' => $data, + 'model_type' => $modelType, + 'model_params' => $modelParams, + 'prediction_options' => $predictionOptions, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/predict", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $prediction = $responseData['prediction'] ?? []; + + return [ + 'success' => true, + 'prediction' => [ + 'data' => $data, + 'model_type' => $modelType, + 'predictions' => array_map(fn ($pred) => [ + 'input' => $pred['input'] ?? [], + 'prediction' => $pred['prediction'] ?? null, + 'confidence' => $pred['confidence'] ?? 0.0, + 'probability' => $pred['probability'] ?? [], + ], $prediction['predictions'] ?? []), + 'model_metrics' => [ + 'accuracy' => $prediction['model_metrics']['accuracy'] ?? 0.0, + 'precision' => $prediction['model_metrics']['precision'] ?? 0.0, + 'recall' => $prediction['model_metrics']['recall'] ?? 0.0, + 'f1_score' => $prediction['model_metrics']['f1_score'] ?? 0.0, + 'auc' => $prediction['model_metrics']['auc'] ?? 0.0, + ], + 'feature_importance' => $prediction['feature_importance'] ?? [], + 'prediction_interval' => [ + 'lower_bound' => $prediction['prediction_interval']['lower_bound'] ?? 0.0, + 'upper_bound' => $prediction['prediction_interval']['upper_bound'] ?? 0.0, + 'confidence_level' => $prediction['prediction_interval']['confidence_level'] ?? 0.95, + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'prediction' => [ + 'data' => $data, + 'model_type' => $modelType, + 'predictions' => [], + 'model_metrics' => [ + 'accuracy' => 0.0, + 'precision' => 0.0, + 'recall' => 0.0, + 'f1_score' => 0.0, + 'auc' => 0.0, + ], + 'feature_importance' => [], + 'prediction_interval' => [ + 'lower_bound' => 0.0, + 'upper_bound' => 0.0, + 'confidence_level' => 0.95, + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Train models. + * + * @param array> $trainingData Training data + * @param string $modelType Model type to train + * @param array $trainingParams Training parameters + * @param array $validationData Validation data + * + * @return array{ + * success: bool, + * training: array{ + * training_data: array>, + * model_type: string, + * model_id: string, + * training_results: array{ + * epochs: int, + * final_loss: float, + * validation_loss: float, + * training_accuracy: float, + * validation_accuracy: float, + * training_time: float, + * }, + * hyperparameters: array, + * feature_importance: array, + * model_performance: array{ + * accuracy: float, + * precision: float, + * recall: float, + * f1_score: float, + * confusion_matrix: array>, + * }, + * model_url: string, + * training_plots: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function train( + array $trainingData, + string $modelType = 'classification', + array $trainingParams = [], + array $validationData = [], + ): array { + try { + $requestData = [ + 'training_data' => $trainingData, + 'model_type' => $modelType, + 'training_params' => $trainingParams, + 'validation_data' => $validationData, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/train", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $training = $responseData['training'] ?? []; + + return [ + 'success' => true, + 'training' => [ + 'training_data' => $trainingData, + 'model_type' => $modelType, + 'model_id' => $training['model_id'] ?? '', + 'training_results' => [ + 'epochs' => $training['training_results']['epochs'] ?? 0, + 'final_loss' => $training['training_results']['final_loss'] ?? 0.0, + 'validation_loss' => $training['training_results']['validation_loss'] ?? 0.0, + 'training_accuracy' => $training['training_results']['training_accuracy'] ?? 0.0, + 'validation_accuracy' => $training['training_results']['validation_accuracy'] ?? 0.0, + 'training_time' => $training['training_results']['training_time'] ?? 0.0, + ], + 'hyperparameters' => $training['hyperparameters'] ?? [], + 'feature_importance' => $training['feature_importance'] ?? [], + 'model_performance' => [ + 'accuracy' => $training['model_performance']['accuracy'] ?? 0.0, + 'precision' => $training['model_performance']['precision'] ?? 0.0, + 'recall' => $training['model_performance']['recall'] ?? 0.0, + 'f1_score' => $training['model_performance']['f1_score'] ?? 0.0, + 'confusion_matrix' => $training['model_performance']['confusion_matrix'] ?? [], + ], + 'model_url' => $training['model_url'] ?? '', + 'training_plots' => array_map(fn ($plot) => [ + 'type' => $plot['type'] ?? '', + 'title' => $plot['title'] ?? '', + 'url' => $plot['url'] ?? '', + ], $training['training_plots'] ?? []), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'training' => [ + 'training_data' => $trainingData, + 'model_type' => $modelType, + 'model_id' => '', + 'training_results' => [ + 'epochs' => 0, + 'final_loss' => 0.0, + 'validation_loss' => 0.0, + 'training_accuracy' => 0.0, + 'validation_accuracy' => 0.0, + 'training_time' => 0.0, + ], + 'hyperparameters' => [], + 'feature_importance' => [], + 'model_performance' => [ + 'accuracy' => 0.0, + 'precision' => 0.0, + 'recall' => 0.0, + 'f1_score' => 0.0, + 'confusion_matrix' => [], + ], + 'model_url' => '', + 'training_plots' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Evaluate models. + * + * @param string $modelId Model ID to evaluate + * @param array> $testData Test data + * @param array $evaluationMetrics Metrics to evaluate + * + * @return array{ + * success: bool, + * evaluation: array{ + * model_id: string, + * test_data: array>, + * evaluation_metrics: array{ + * accuracy: float, + * precision: float, + * recall: float, + * f1_score: float, + * auc: float, + * mse: float, + * rmse: float, + * mae: float, + * r2_score: float, + * }, + * confusion_matrix: array>, + * classification_report: array, + * feature_importance: array, + * predictions: array, + * evaluation_plots: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function evaluate( + string $modelId, + array $testData, + array $evaluationMetrics = ['accuracy', 'precision', 'recall', 'f1_score'], + ): array { + try { + $requestData = [ + 'model_id' => $modelId, + 'test_data' => $testData, + 'evaluation_metrics' => $evaluationMetrics, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/evaluate", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $evaluation = $responseData['evaluation'] ?? []; + + return [ + 'success' => true, + 'evaluation' => [ + 'model_id' => $modelId, + 'test_data' => $testData, + 'evaluation_metrics' => [ + 'accuracy' => $evaluation['evaluation_metrics']['accuracy'] ?? 0.0, + 'precision' => $evaluation['evaluation_metrics']['precision'] ?? 0.0, + 'recall' => $evaluation['evaluation_metrics']['recall'] ?? 0.0, + 'f1_score' => $evaluation['evaluation_metrics']['f1_score'] ?? 0.0, + 'auc' => $evaluation['evaluation_metrics']['auc'] ?? 0.0, + 'mse' => $evaluation['evaluation_metrics']['mse'] ?? 0.0, + 'rmse' => $evaluation['evaluation_metrics']['rmse'] ?? 0.0, + 'mae' => $evaluation['evaluation_metrics']['mae'] ?? 0.0, + 'r2_score' => $evaluation['evaluation_metrics']['r2_score'] ?? 0.0, + ], + 'confusion_matrix' => $evaluation['confusion_matrix'] ?? [], + 'classification_report' => $evaluation['classification_report'] ?? [], + 'feature_importance' => $evaluation['feature_importance'] ?? [], + 'predictions' => array_map(fn ($pred) => [ + 'actual' => $pred['actual'] ?? null, + 'predicted' => $pred['predicted'] ?? null, + 'confidence' => $pred['confidence'] ?? 0.0, + ], $evaluation['predictions'] ?? []), + 'evaluation_plots' => array_map(fn ($plot) => [ + 'type' => $plot['type'] ?? '', + 'title' => $plot['title'] ?? '', + 'url' => $plot['url'] ?? '', + ], $evaluation['evaluation_plots'] ?? []), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'evaluation' => [ + 'model_id' => $modelId, + 'test_data' => $testData, + 'evaluation_metrics' => [ + 'accuracy' => 0.0, + 'precision' => 0.0, + 'recall' => 0.0, + 'f1_score' => 0.0, + 'auc' => 0.0, + 'mse' => 0.0, + 'rmse' => 0.0, + 'mae' => 0.0, + 'r2_score' => 0.0, + ], + 'confusion_matrix' => [], + 'classification_report' => [], + 'feature_importance' => [], + 'predictions' => [], + 'evaluation_plots' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Deploy models. + * + * @param string $modelId Model ID to deploy + * @param string $deploymentType Deployment type + * @param array $deploymentConfig Deployment configuration + * + * @return array{ + * success: bool, + * deployment: array{ + * model_id: string, + * deployment_id: string, + * deployment_type: string, + * status: string, + * endpoint_url: string, + * api_key: string, + * deployment_config: array, + * health_check: array{ + * status: string, + * response_time: float, + * uptime: float, + * }, + * scaling_config: array{ + * min_instances: int, + * max_instances: int, + * target_cpu: float, + * }, + * monitoring: array{ + * metrics_url: string, + * logs_url: string, + * alerts_enabled: bool, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function deploy( + string $modelId, + string $deploymentType = 'api', + array $deploymentConfig = [], + ): array { + try { + $requestData = [ + 'model_id' => $modelId, + 'deployment_type' => $deploymentType, + 'deployment_config' => $deploymentConfig, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/deploy", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $deployment = $responseData['deployment'] ?? []; + + return [ + 'success' => true, + 'deployment' => [ + 'model_id' => $modelId, + 'deployment_id' => $deployment['deployment_id'] ?? '', + 'deployment_type' => $deploymentType, + 'status' => $deployment['status'] ?? 'deployed', + 'endpoint_url' => $deployment['endpoint_url'] ?? '', + 'api_key' => $deployment['api_key'] ?? '', + 'deployment_config' => $deploymentConfig, + 'health_check' => [ + 'status' => $deployment['health_check']['status'] ?? 'healthy', + 'response_time' => $deployment['health_check']['response_time'] ?? 0.0, + 'uptime' => $deployment['health_check']['uptime'] ?? 0.0, + ], + 'scaling_config' => [ + 'min_instances' => $deployment['scaling_config']['min_instances'] ?? 1, + 'max_instances' => $deployment['scaling_config']['max_instances'] ?? 10, + 'target_cpu' => $deployment['scaling_config']['target_cpu'] ?? 0.7, + ], + 'monitoring' => [ + 'metrics_url' => $deployment['monitoring']['metrics_url'] ?? '', + 'logs_url' => $deployment['monitoring']['logs_url'] ?? '', + 'alerts_enabled' => $deployment['monitoring']['alerts_enabled'] ?? false, + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'deployment' => [ + 'model_id' => $modelId, + 'deployment_id' => '', + 'deployment_type' => $deploymentType, + 'status' => 'failed', + 'endpoint_url' => '', + 'api_key' => '', + 'deployment_config' => $deploymentConfig, + 'health_check' => [ + 'status' => 'unhealthy', + 'response_time' => 0.0, + 'uptime' => 0.0, + ], + 'scaling_config' => [ + 'min_instances' => 1, + 'max_instances' => 10, + 'target_cpu' => 0.7, + ], + 'monitoring' => [ + 'metrics_url' => '', + 'logs_url' => '', + 'alerts_enabled' => false, + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Optimize models. + * + * @param string $modelId Model ID to optimize + * @param array $optimizationParams Optimization parameters + * @param array $optimizationTargets Optimization targets + * + * @return array{ + * success: bool, + * optimization: array{ + * model_id: string, + * optimization_params: array, + * optimization_targets: array, + * optimized_model_id: string, + * optimization_results: array{ + * original_performance: array, + * optimized_performance: array, + * improvement: array, + * optimization_time: float, + * }, + * hyperparameter_tuning: array{ + * best_params: array, + * search_space: array, + * trials: int, + * }, + * model_compression: array{ + * original_size: float, + * compressed_size: float, + * compression_ratio: float, + * accuracy_loss: float, + * }, + * optimization_plots: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function optimize( + string $modelId, + array $optimizationParams = [], + array $optimizationTargets = ['accuracy', 'speed'], + ): array { + try { + $requestData = [ + 'model_id' => $modelId, + 'optimization_params' => $optimizationParams, + 'optimization_targets' => $optimizationTargets, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/optimize", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $optimization = $responseData['optimization'] ?? []; + + return [ + 'success' => true, + 'optimization' => [ + 'model_id' => $modelId, + 'optimization_params' => $optimizationParams, + 'optimization_targets' => $optimizationTargets, + 'optimized_model_id' => $optimization['optimized_model_id'] ?? '', + 'optimization_results' => [ + 'original_performance' => $optimization['optimization_results']['original_performance'] ?? [], + 'optimized_performance' => $optimization['optimization_results']['optimized_performance'] ?? [], + 'improvement' => $optimization['optimization_results']['improvement'] ?? [], + 'optimization_time' => $optimization['optimization_results']['optimization_time'] ?? 0.0, + ], + 'hyperparameter_tuning' => [ + 'best_params' => $optimization['hyperparameter_tuning']['best_params'] ?? [], + 'search_space' => $optimization['hyperparameter_tuning']['search_space'] ?? [], + 'trials' => $optimization['hyperparameter_tuning']['trials'] ?? 0, + ], + 'model_compression' => [ + 'original_size' => $optimization['model_compression']['original_size'] ?? 0.0, + 'compressed_size' => $optimization['model_compression']['compressed_size'] ?? 0.0, + 'compression_ratio' => $optimization['model_compression']['compression_ratio'] ?? 0.0, + 'accuracy_loss' => $optimization['model_compression']['accuracy_loss'] ?? 0.0, + ], + 'optimization_plots' => array_map(fn ($plot) => [ + 'type' => $plot['type'] ?? '', + 'title' => $plot['title'] ?? '', + 'url' => $plot['url'] ?? '', + ], $optimization['optimization_plots'] ?? []), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'optimization' => [ + 'model_id' => $modelId, + 'optimization_params' => $optimizationParams, + 'optimization_targets' => $optimizationTargets, + 'optimized_model_id' => '', + 'optimization_results' => [ + 'original_performance' => [], + 'optimized_performance' => [], + 'improvement' => [], + 'optimization_time' => 0.0, + ], + 'hyperparameter_tuning' => [ + 'best_params' => [], + 'search_space' => [], + 'trials' => 0, + ], + 'model_compression' => [ + 'original_size' => 0.0, + 'compressed_size' => 0.0, + 'compression_ratio' => 0.0, + 'accuracy_loss' => 0.0, + ], + 'optimization_plots' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create visualizations. + * + * @param array> $data Data to visualize + * @param string $visualizationType Type of visualization + * @param array $options Visualization options + * + * @return array{ + * success: bool, + * visualization: array{ + * data: array>, + * visualization_type: string, + * charts: array, + * dashboard_url: string, + * insights: array, + * export_formats: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function visualize( + array $data, + string $visualizationType = 'dashboard', + array $options = [], + ): array { + try { + $requestData = [ + 'data' => $data, + 'visualization_type' => $visualizationType, + 'options' => $options, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/visualize", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $visualization = $responseData['visualization'] ?? []; + + return [ + 'success' => true, + 'visualization' => [ + 'data' => $data, + 'visualization_type' => $visualizationType, + 'charts' => array_map(fn ($chart) => [ + 'chart_id' => $chart['chart_id'] ?? '', + 'type' => $chart['type'] ?? '', + 'title' => $chart['title'] ?? '', + 'url' => $chart['url'] ?? '', + 'embed_code' => $chart['embed_code'] ?? '', + ], $visualization['charts'] ?? []), + 'dashboard_url' => $visualization['dashboard_url'] ?? '', + 'insights' => $visualization['insights'] ?? [], + 'export_formats' => $visualization['export_formats'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'visualization' => [ + 'data' => $data, + 'visualization_type' => $visualizationType, + 'charts' => [], + 'dashboard_url' => '', + 'insights' => [], + 'export_formats' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Export results. + * + * @param string $exportId Export ID or data identifier + * @param string $format Export format + * @param array $exportOptions Export options + * + * @return array{ + * success: bool, + * export: array{ + * export_id: string, + * format: string, + * file_url: string, + * file_size: int, + * download_url: string, + * expires_at: string, + * metadata: array{ + * created_at: string, + * record_count: int, + * columns: array, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function export( + string $exportId, + string $format = 'csv', + array $exportOptions = [], + ): array { + try { + $requestData = [ + 'export_id' => $exportId, + 'format' => $format, + 'export_options' => $exportOptions, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/export", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $export = $responseData['export'] ?? []; + + return [ + 'success' => true, + 'export' => [ + 'export_id' => $exportId, + 'format' => $format, + 'file_url' => $export['file_url'] ?? '', + 'file_size' => $export['file_size'] ?? 0, + 'download_url' => $export['download_url'] ?? '', + 'expires_at' => $export['expires_at'] ?? '', + 'metadata' => [ + 'created_at' => $export['metadata']['created_at'] ?? date('c'), + 'record_count' => $export['metadata']['record_count'] ?? 0, + 'columns' => $export['metadata']['columns'] ?? [], + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'export' => [ + 'export_id' => $exportId, + 'format' => $format, + 'file_url' => '', + 'file_size' => 0, + 'download_url' => '', + 'expires_at' => '', + 'metadata' => [ + 'created_at' => date('c'), + 'record_count' => 0, + 'columns' => [], + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Salesforce.php b/src/agent/src/Toolbox/Tool/Salesforce.php new file mode 100644 index 000000000..30a05a1c9 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Salesforce.php @@ -0,0 +1,511 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('salesforce_query', 'Tool that queries Salesforce data')] +#[AsTool('salesforce_create_record', 'Tool that creates Salesforce records', method: 'createRecord')] +#[AsTool('salesforce_update_record', 'Tool that updates Salesforce records', method: 'updateRecord')] +#[AsTool('salesforce_delete_record', 'Tool that deletes Salesforce records', method: 'deleteRecord')] +#[AsTool('salesforce_get_record', 'Tool that gets Salesforce records', method: 'getRecord')] +#[AsTool('salesforce_search_records', 'Tool that searches Salesforce records', method: 'searchRecords')] +final readonly class Salesforce +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + #[\SensitiveParameter] private string $instanceUrl, + private string $apiVersion = 'v58.0', + private array $options = [], + ) { + } + + /** + * Query Salesforce data using SOQL. + * + * @param string $query SOQL query string + * + * @return array{ + * totalSize: int, + * done: bool, + * records: array>, + * nextRecordsUrl: string|null, + * }|string + */ + public function __invoke( + #[With(maximum: 10000)] + string $query, + ): array|string { + try { + $response = $this->httpClient->request('GET', "{$this->instanceUrl}/services/data/{$this->apiVersion}/query/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'query' => [ + 'q' => $query, + ], + ]); + + $data = $response->toArray(); + + if (isset($data[0]['errorCode'])) { + return 'Error querying Salesforce: '.($data[0]['message'] ?? 'Unknown error'); + } + + return [ + 'totalSize' => $data['totalSize'], + 'done' => $data['done'], + 'records' => $data['records'], + 'nextRecordsUrl' => $data['nextRecordsUrl'] ?? null, + ]; + } catch (\Exception $e) { + return 'Error querying Salesforce: '.$e->getMessage(); + } + } + + /** + * Create a Salesforce record. + * + * @param string $objectType Salesforce object type (e.g., 'Account', 'Contact', 'Lead') + * @param array $fields Record fields + * + * @return array{ + * id: string, + * success: bool, + * errors: array}>, + * }|string + */ + public function createRecord( + string $objectType, + array $fields, + ): array|string { + try { + $response = $this->httpClient->request('POST', "{$this->instanceUrl}/services/data/{$this->apiVersion}/sobjects/{$objectType}/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $fields, + ]); + + $data = $response->toArray(); + + if (isset($data[0]['errorCode'])) { + return 'Error creating record: '.($data[0]['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'success' => $data['success'] ?? true, + 'errors' => $data['errors'] ?? [], + ]; + } catch (\Exception $e) { + return 'Error creating record: '.$e->getMessage(); + } + } + + /** + * Update a Salesforce record. + * + * @param string $objectType Salesforce object type + * @param string $recordId Record ID + * @param array $fields Fields to update + */ + public function updateRecord( + string $objectType, + string $recordId, + array $fields, + ): string { + try { + $response = $this->httpClient->request('PATCH', "{$this->instanceUrl}/services/data/{$this->apiVersion}/sobjects/{$objectType}/{$recordId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $fields, + ]); + + if (204 === $response->getStatusCode()) { + return "Record {$recordId} updated successfully"; + } else { + $data = $response->toArray(); + + return 'Error updating record: '.($data[0]['message'] ?? 'Unknown error'); + } + } catch (\Exception $e) { + return 'Error updating record: '.$e->getMessage(); + } + } + + /** + * Delete a Salesforce record. + * + * @param string $objectType Salesforce object type + * @param string $recordId Record ID + */ + public function deleteRecord( + string $objectType, + string $recordId, + ): string { + try { + $response = $this->httpClient->request('DELETE', "{$this->instanceUrl}/services/data/{$this->apiVersion}/sobjects/{$objectType}/{$recordId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + ]); + + if (204 === $response->getStatusCode()) { + return "Record {$recordId} deleted successfully"; + } else { + $data = $response->toArray(); + + return 'Error deleting record: '.($data[0]['message'] ?? 'Unknown error'); + } + } catch (\Exception $e) { + return 'Error deleting record: '.$e->getMessage(); + } + } + + /** + * Get a Salesforce record. + * + * @param string $objectType Salesforce object type + * @param string $recordId Record ID + * @param string $fields Comma-separated list of fields to retrieve + * + * @return array|string + */ + public function getRecord( + string $objectType, + string $recordId, + string $fields = '', + ): array|string { + try { + $url = "{$this->instanceUrl}/services/data/{$this->apiVersion}/sobjects/{$objectType}/{$recordId}"; + + $params = []; + if ($fields) { + $params['fields'] = $fields; + } + + $response = $this->httpClient->request('GET', $url, [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'query' => $params, + ]); + + $data = $response->toArray(); + + if (isset($data[0]['errorCode'])) { + return 'Error getting record: '.($data[0]['message'] ?? 'Unknown error'); + } + + return $data; + } catch (\Exception $e) { + return 'Error getting record: '.$e->getMessage(); + } + } + + /** + * Search Salesforce records using SOSL. + * + * @param string $searchQuery SOSL search query + * + * @return array|string + */ + public function searchRecords(string $searchQuery): array|string + { + try { + $response = $this->httpClient->request('GET', "{$this->instanceUrl}/services/data/{$this->apiVersion}/search/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'query' => [ + 'q' => $searchQuery, + ], + ]); + + $data = $response->toArray(); + + if (isset($data[0]['errorCode'])) { + return 'Error searching records: '.($data[0]['message'] ?? 'Unknown error'); + } + + $results = []; + foreach ($data['searchRecords'] as $record) { + $results[] = [ + 'type' => $record['type'], + 'id' => $record['Id'], + 'attributes' => [ + 'type' => $record['attributes']['type'], + 'url' => $record['attributes']['url'], + ], + ]; + } + + return $results; + } catch (\Exception $e) { + return 'Error searching records: '.$e->getMessage(); + } + } + + /** + * Get Salesforce object metadata. + * + * @param string $objectType Salesforce object type + * + * @return array{ + * name: string, + * label: string, + * fields: array, + * defaultValue: mixed, + * inlineHelpText: string, + * helpText: string, + * }>, + * }|string + */ + public function getObjectMetadata(string $objectType): array|string + { + try { + $response = $this->httpClient->request('GET', "{$this->instanceUrl}/services/data/{$this->apiVersion}/sobjects/{$objectType}/describe/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + ]); + + $data = $response->toArray(); + + if (isset($data[0]['errorCode'])) { + return 'Error getting object metadata: '.($data[0]['message'] ?? 'Unknown error'); + } + + return [ + 'name' => $data['name'], + 'label' => $data['label'], + 'fields' => array_map(fn ($field) => [ + 'name' => $field['name'], + 'type' => $field['type'], + 'label' => $field['label'], + 'length' => $field['length'] ?? 0, + 'required' => false === $field['nillable'] && false === $field['defaultedOnCreate'], + 'unique' => $field['unique'] ?? false, + 'nillable' => $field['nillable'], + 'createable' => $field['createable'], + 'updateable' => $field['updateable'], + 'queryable' => $field['queryable'], + 'sortable' => $field['sortable'], + 'filterable' => $field['filterable'], + 'calculated' => $field['calculated'] ?? false, + 'cascadeDelete' => $field['cascadeDelete'] ?? false, + 'restrictedPicklist' => $field['restrictedPicklist'] ?? false, + 'nameField' => $field['nameField'] ?? false, + 'autoNumber' => $field['autoNumber'] ?? false, + 'byteLength' => $field['byteLength'] ?? 0, + 'digits' => $field['digits'] ?? 0, + 'precision' => $field['precision'] ?? 0, + 'scale' => $field['scale'] ?? 0, + 'picklistValues' => array_map(fn ($value) => [ + 'active' => $value['active'], + 'defaultValue' => $value['defaultValue'], + 'label' => $value['label'], + 'validFor' => $value['validFor'] ?? '', + 'value' => $value['value'], + ], $field['picklistValues'] ?? []), + 'defaultValue' => $field['defaultValue'] ?? null, + 'inlineHelpText' => $field['inlineHelpText'] ?? '', + 'helpText' => $field['helpText'] ?? '', + ], $data['fields']), + ]; + } catch (\Exception $e) { + return 'Error getting object metadata: '.$e->getMessage(); + } + } + + /** + * Create multiple Salesforce records in bulk. + * + * @param string $objectType Salesforce object type + * @param array> $records Array of records to create + * + * @return array}>, + * }>|string + */ + public function bulkCreateRecords( + string $objectType, + array $records, + ): array|string { + try { + $payload = [ + 'allOrNone' => false, + 'records' => $records, + ]; + + $response = $this->httpClient->request('POST', "{$this->instanceUrl}/services/data/{$this->apiVersion}/composite/sobjects", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data[0]['errorCode'])) { + return 'Error creating records: '.($data[0]['message'] ?? 'Unknown error'); + } + + return array_map(fn ($result) => [ + 'id' => $result['id'] ?? '', + 'success' => $result['success'] ?? false, + 'errors' => $result['errors'] ?? [], + ], $data); + } catch (\Exception $e) { + return 'Error creating records: '.$e->getMessage(); + } + } + + /** + * Get Salesforce organization information. + * + * @return array{ + * id: string, + * name: string, + * country: string, + * currencyIsoCode: string, + * languageLocaleKey: string, + * timeZoneSidKey: string, + * organizationType: string, + * trialExpirationDate: string, + * instanceName: string, + * isSandbox: bool, + * complianceBccEmail: string, + * complianceEmail: string, + * defaultCurrencyIsoCode: string, + * defaultLocaleSidKey: string, + * defaultTimeZoneSidKey: string, + * division: string, + * fax: string, + * phone: string, + * street: string, + * city: string, + * state: string, + * postalCode: string, + * countryCode: string, + * features: array, + * settings: array, + * }|string + */ + public function getOrganizationInfo(): array|string + { + try { + $response = $this->httpClient->request('GET', "{$this->instanceUrl}/services/data/{$this->apiVersion}/sobjects/Organization/describe/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + ]); + + $data = $response->toArray(); + + if (isset($data[0]['errorCode'])) { + return 'Error getting organization info: '.($data[0]['message'] ?? 'Unknown error'); + } + + // Get organization details by querying the Organization object + $orgQuery = $this->httpClient->request('GET', "{$this->instanceUrl}/services/data/{$this->apiVersion}/query/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'query' => [ + 'q' => 'SELECT Id, Name, Country, CurrencyIsoCode, LanguageLocaleKey, TimeZoneSidKey, OrganizationType, TrialExpirationDate, InstanceName, IsSandbox, ComplianceBccEmail, ComplianceEmail, DefaultCurrencyIsoCode, DefaultLocaleSidKey, DefaultTimeZoneSidKey, Division, Fax, Phone, Street, City, State, PostalCode, CountryCode FROM Organization LIMIT 1', + ], + ]); + + $orgData = $orgQuery->toArray(); + $org = $orgData['records'][0] ?? []; + + return [ + 'id' => $org['Id'] ?? '', + 'name' => $org['Name'] ?? '', + 'country' => $org['Country'] ?? '', + 'currencyIsoCode' => $org['CurrencyIsoCode'] ?? '', + 'languageLocaleKey' => $org['LanguageLocaleKey'] ?? '', + 'timeZoneSidKey' => $org['TimeZoneSidKey'] ?? '', + 'organizationType' => $org['OrganizationType'] ?? '', + 'trialExpirationDate' => $org['TrialExpirationDate'] ?? '', + 'instanceName' => $org['InstanceName'] ?? '', + 'isSandbox' => $org['IsSandbox'] ?? false, + 'complianceBccEmail' => $org['ComplianceBccEmail'] ?? '', + 'complianceEmail' => $org['ComplianceEmail'] ?? '', + 'defaultCurrencyIsoCode' => $org['DefaultCurrencyIsoCode'] ?? '', + 'defaultLocaleSidKey' => $org['DefaultLocaleSidKey'] ?? '', + 'defaultTimeZoneSidKey' => $org['DefaultTimeZoneSidKey'] ?? '', + 'division' => $org['Division'] ?? '', + 'fax' => $org['Fax'] ?? '', + 'phone' => $org['Phone'] ?? '', + 'street' => $org['Street'] ?? '', + 'city' => $org['City'] ?? '', + 'state' => $org['State'] ?? '', + 'postalCode' => $org['PostalCode'] ?? '', + 'countryCode' => $org['CountryCode'] ?? '', + 'features' => [], + 'settings' => [], + ]; + } catch (\Exception $e) { + return 'Error getting organization info: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/SceneExplain.php b/src/agent/src/Toolbox/Tool/SceneExplain.php new file mode 100644 index 000000000..44dbf6bcb --- /dev/null +++ b/src/agent/src/Toolbox/Tool/SceneExplain.php @@ -0,0 +1,862 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('scene_explain_analyze', 'Tool that analyzes and explains scenes in images')] +#[AsTool('scene_explain_describe', 'Tool that describes visual scenes', method: 'describeScene')] +#[AsTool('scene_explain_identify_objects', 'Tool that identifies objects in scenes', method: 'identifyObjects')] +#[AsTool('scene_explain_analyze_relationships', 'Tool that analyzes object relationships', method: 'analyzeRelationships')] +#[AsTool('scene_explain_detect_activities', 'Tool that detects activities in scenes', method: 'detectActivities')] +#[AsTool('scene_explain_analyze_context', 'Tool that analyzes scene context', method: 'analyzeContext')] +#[AsTool('scene_explain_generate_story', 'Tool that generates stories from scenes', method: 'generateStory')] +#[AsTool('scene_explain_answer_questions', 'Tool that answers questions about scenes', method: 'answerQuestions')] +final readonly class SceneExplain +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.sceneexplain.com', + private array $options = [], + ) { + } + + /** + * Analyze and explain scenes in images. + * + * @param string $imageUrl URL or base64 encoded image + * @param string $analysisType Type of analysis (basic, detailed, comprehensive) + * @param array $options Analysis options + * + * @return array{ + * success: bool, + * scene_analysis: array{ + * image_url: string, + * analysis_type: string, + * scene_description: string, + * detected_objects: array, + * }>, + * scene_context: array{ + * location_type: string, + * time_of_day: string, + * weather: string, + * mood: string, + * activity_type: string, + * }, + * relationships: array, + * key_insights: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $imageUrl, + string $analysisType = 'detailed', + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'analysis_type' => $analysisType, + 'options' => array_merge([ + 'include_relationships' => $options['include_relationships'] ?? true, + 'include_context' => $options['include_context'] ?? true, + 'include_activities' => $options['include_activities'] ?? true, + 'confidence_threshold' => $options['confidence_threshold'] ?? 0.5, + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/analyze", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'scene_analysis' => [ + 'image_url' => $imageUrl, + 'analysis_type' => $analysisType, + 'scene_description' => $responseData['scene_description'] ?? '', + 'detected_objects' => array_map(fn ($obj) => [ + 'object_name' => $obj['name'] ?? '', + 'confidence' => $obj['confidence'] ?? 0.0, + 'bounding_box' => [ + 'x' => $obj['bbox']['x'] ?? 0, + 'y' => $obj['bbox']['y'] ?? 0, + 'width' => $obj['bbox']['width'] ?? 0, + 'height' => $obj['bbox']['height'] ?? 0, + ], + 'attributes' => $obj['attributes'] ?? [], + ], $responseData['objects'] ?? []), + 'scene_context' => [ + 'location_type' => $responseData['context']['location'] ?? '', + 'time_of_day' => $responseData['context']['time_of_day'] ?? '', + 'weather' => $responseData['context']['weather'] ?? '', + 'mood' => $responseData['context']['mood'] ?? '', + 'activity_type' => $responseData['context']['activity'] ?? '', + ], + 'relationships' => array_map(fn ($rel) => [ + 'subject' => $rel['subject'] ?? '', + 'predicate' => $rel['predicate'] ?? '', + 'object' => $rel['object'] ?? '', + 'confidence' => $rel['confidence'] ?? 0.0, + ], $responseData['relationships'] ?? []), + 'key_insights' => $responseData['insights'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'scene_analysis' => [ + 'image_url' => $imageUrl, + 'analysis_type' => $analysisType, + 'scene_description' => '', + 'detected_objects' => [], + 'scene_context' => [ + 'location_type' => '', + 'time_of_day' => '', + 'weather' => '', + 'mood' => '', + 'activity_type' => '', + ], + 'relationships' => [], + 'key_insights' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Describe visual scenes. + * + * @param string $imageUrl URL or base64 encoded image + * @param string $descriptionStyle Style of description + * @param array $options Description options + * + * @return array{ + * success: bool, + * scene_description: array{ + * image_url: string, + * description_style: string, + * description: string, + * detailed_description: string, + * summary: string, + * visual_elements: array, + * color_palette: array, + * composition_notes: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function describeScene( + string $imageUrl, + string $descriptionStyle = 'natural', + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'description_style' => $descriptionStyle, + 'options' => array_merge([ + 'include_colors' => $options['include_colors'] ?? true, + 'include_composition' => $options['include_composition'] ?? true, + 'detail_level' => $options['detail_level'] ?? 'medium', + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/describe", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'scene_description' => [ + 'image_url' => $imageUrl, + 'description_style' => $descriptionStyle, + 'description' => $responseData['description'] ?? '', + 'detailed_description' => $responseData['detailed_description'] ?? '', + 'summary' => $responseData['summary'] ?? '', + 'visual_elements' => $responseData['visual_elements'] ?? [], + 'color_palette' => $responseData['color_palette'] ?? [], + 'composition_notes' => $responseData['composition'] ?? '', + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'scene_description' => [ + 'image_url' => $imageUrl, + 'description_style' => $descriptionStyle, + 'description' => '', + 'detailed_description' => '', + 'summary' => '', + 'visual_elements' => [], + 'color_palette' => [], + 'composition_notes' => '', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Identify objects in scenes. + * + * @param string $imageUrl URL or base64 encoded image + * @param array $objectCategories Object categories to detect + * @param array $options Detection options + * + * @return array{ + * success: bool, + * object_identification: array{ + * image_url: string, + * object_categories: array, + * detected_objects: array, + * relationships: array, + * }>, + * object_count: int, + * category_distribution: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function identifyObjects( + string $imageUrl, + array $objectCategories = [], + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'object_categories' => $objectCategories ?: ['person', 'vehicle', 'animal', 'furniture', 'food'], + 'options' => array_merge([ + 'confidence_threshold' => $options['confidence_threshold'] ?? 0.5, + 'include_attributes' => $options['include_attributes'] ?? true, + 'include_relationships' => $options['include_relationships'] ?? true, + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/identify-objects", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $objects = $responseData['objects'] ?? []; + + return [ + 'success' => true, + 'object_identification' => [ + 'image_url' => $imageUrl, + 'object_categories' => $objectCategories, + 'detected_objects' => array_map(fn ($obj) => [ + 'object_name' => $obj['name'] ?? '', + 'category' => $obj['category'] ?? '', + 'confidence' => $obj['confidence'] ?? 0.0, + 'bounding_box' => [ + 'x' => $obj['bbox']['x'] ?? 0, + 'y' => $obj['bbox']['y'] ?? 0, + 'width' => $obj['bbox']['width'] ?? 0, + 'height' => $obj['bbox']['height'] ?? 0, + ], + 'attributes' => $obj['attributes'] ?? [], + 'relationships' => $obj['relationships'] ?? [], + ], $objects), + 'object_count' => \count($objects), + 'category_distribution' => $this->countCategories($objects), + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'object_identification' => [ + 'image_url' => $imageUrl, + 'object_categories' => $objectCategories, + 'detected_objects' => [], + 'object_count' => 0, + 'category_distribution' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze object relationships. + * + * @param string $imageUrl URL or base64 encoded image + * @param array $relationshipTypes Types of relationships to analyze + * @param array $options Analysis options + * + * @return array{ + * success: bool, + * relationship_analysis: array{ + * image_url: string, + * relationship_types: array, + * relationships: array, + * relationship_graph: array>, + * dominant_relationships: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function analyzeRelationships( + string $imageUrl, + array $relationshipTypes = [], + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'relationship_types' => $relationshipTypes ?: ['spatial', 'functional', 'temporal', 'social'], + 'options' => array_merge([ + 'include_spatial' => $options['include_spatial'] ?? true, + 'include_functional' => $options['include_functional'] ?? true, + 'confidence_threshold' => $options['confidence_threshold'] ?? 0.5, + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/analyze-relationships", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'relationship_analysis' => [ + 'image_url' => $imageUrl, + 'relationship_types' => $relationshipTypes, + 'relationships' => array_map(fn ($rel) => [ + 'subject' => $rel['subject'] ?? '', + 'predicate' => $rel['predicate'] ?? '', + 'object' => $rel['object'] ?? '', + 'confidence' => $rel['confidence'] ?? 0.0, + 'relationship_type' => $rel['type'] ?? '', + 'spatial_info' => [ + 'distance' => $rel['spatial']['distance'] ?? '', + 'direction' => $rel['spatial']['direction'] ?? '', + 'relative_position' => $rel['spatial']['position'] ?? '', + ], + ], $responseData['relationships'] ?? []), + 'relationship_graph' => $responseData['graph'] ?? [], + 'dominant_relationships' => $responseData['dominant'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'relationship_analysis' => [ + 'image_url' => $imageUrl, + 'relationship_types' => $relationshipTypes, + 'relationships' => [], + 'relationship_graph' => [], + 'dominant_relationships' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Detect activities in scenes. + * + * @param string $imageUrl URL or base64 encoded image + * @param array $activityTypes Activity types to detect + * @param array $options Detection options + * + * @return array{ + * success: bool, + * activity_detection: array{ + * image_url: string, + * activity_types: array, + * detected_activities: array, + * location: string, + * duration_estimate: string, + * intensity_level: string, + * }>, + * activity_summary: string, + * primary_activity: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function detectActivities( + string $imageUrl, + array $activityTypes = [], + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'activity_types' => $activityTypes ?: ['sports', 'work', 'leisure', 'social', 'transportation'], + 'options' => array_merge([ + 'confidence_threshold' => $options['confidence_threshold'] ?? 0.5, + 'include_participants' => $options['include_participants'] ?? true, + 'include_context' => $options['include_context'] ?? true, + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/detect-activities", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'activity_detection' => [ + 'image_url' => $imageUrl, + 'activity_types' => $activityTypes, + 'detected_activities' => array_map(fn ($activity) => [ + 'activity_name' => $activity['name'] ?? '', + 'confidence' => $activity['confidence'] ?? 0.0, + 'participants' => $activity['participants'] ?? [], + 'location' => $activity['location'] ?? '', + 'duration_estimate' => $activity['duration'] ?? '', + 'intensity_level' => $activity['intensity'] ?? '', + ], $responseData['activities'] ?? []), + 'activity_summary' => $responseData['summary'] ?? '', + 'primary_activity' => $responseData['primary_activity'] ?? '', + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'activity_detection' => [ + 'image_url' => $imageUrl, + 'activity_types' => $activityTypes, + 'detected_activities' => [], + 'activity_summary' => '', + 'primary_activity' => '', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze scene context. + * + * @param string $imageUrl URL or base64 encoded image + * @param array $contextTypes Context types to analyze + * @param array $options Analysis options + * + * @return array{ + * success: bool, + * context_analysis: array{ + * image_url: string, + * context_types: array, + * scene_context: array{ + * location: array{ + * type: string, + * name: string, + * characteristics: array, + * }, + * temporal_context: array{ + * time_of_day: string, + * season: string, + * weather: string, + * }, + * social_context: array{ + * number_of_people: int, + * social_setting: string, + * formality_level: string, + * }, + * cultural_context: array{ + * cultural_indicators: array, + * language_signs: array, + * }, + * }, + * context_confidence: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function analyzeContext( + string $imageUrl, + array $contextTypes = [], + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'context_types' => $contextTypes ?: ['spatial', 'temporal', 'social', 'cultural'], + 'options' => array_merge([ + 'include_cultural_analysis' => $options['include_cultural'] ?? true, + 'include_social_analysis' => $options['include_social'] ?? true, + 'confidence_threshold' => $options['confidence_threshold'] ?? 0.5, + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/analyze-context", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'context_analysis' => [ + 'image_url' => $imageUrl, + 'context_types' => $contextTypes, + 'scene_context' => [ + 'location' => [ + 'type' => $responseData['location']['type'] ?? '', + 'name' => $responseData['location']['name'] ?? '', + 'characteristics' => $responseData['location']['characteristics'] ?? [], + ], + 'temporal_context' => [ + 'time_of_day' => $responseData['temporal']['time_of_day'] ?? '', + 'season' => $responseData['temporal']['season'] ?? '', + 'weather' => $responseData['temporal']['weather'] ?? '', + ], + 'social_context' => [ + 'number_of_people' => $responseData['social']['people_count'] ?? 0, + 'social_setting' => $responseData['social']['setting'] ?? '', + 'formality_level' => $responseData['social']['formality'] ?? '', + ], + 'cultural_context' => [ + 'cultural_indicators' => $responseData['cultural']['indicators'] ?? [], + 'language_signs' => $responseData['cultural']['language_signs'] ?? [], + ], + ], + 'context_confidence' => $responseData['confidence_scores'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'context_analysis' => [ + 'image_url' => $imageUrl, + 'context_types' => $contextTypes, + 'scene_context' => [ + 'location' => [ + 'type' => '', + 'name' => '', + 'characteristics' => [], + ], + 'temporal_context' => [ + 'time_of_day' => '', + 'season' => '', + 'weather' => '', + ], + 'social_context' => [ + 'number_of_people' => 0, + 'social_setting' => '', + 'formality_level' => '', + ], + 'cultural_context' => [ + 'cultural_indicators' => [], + 'language_signs' => [], + ], + ], + 'context_confidence' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Generate stories from scenes. + * + * @param string $imageUrl URL or base64 encoded image + * @param string $storyStyle Style of story (narrative, descriptive, creative) + * @param array $storyOptions Story generation options + * + * @return array{ + * success: bool, + * story_generation: array{ + * image_url: string, + * story_style: string, + * story: string, + * story_elements: array{ + * characters: array, + * setting: string, + * plot_points: array, + * themes: array, + * }, + * story_metadata: array{ + * word_count: int, + * reading_time: float, + * complexity_level: string, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function generateStory( + string $imageUrl, + string $storyStyle = 'narrative', + array $storyOptions = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'story_style' => $storyStyle, + 'options' => array_merge([ + 'include_characters' => $storyOptions['include_characters'] ?? true, + 'include_setting' => $storyOptions['include_setting'] ?? true, + 'story_length' => $storyOptions['length'] ?? 'medium', + 'target_audience' => $storyOptions['audience'] ?? 'general', + ], $storyOptions), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/generate-story", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'story_generation' => [ + 'image_url' => $imageUrl, + 'story_style' => $storyStyle, + 'story' => $responseData['story'] ?? '', + 'story_elements' => [ + 'characters' => $responseData['elements']['characters'] ?? [], + 'setting' => $responseData['elements']['setting'] ?? '', + 'plot_points' => $responseData['elements']['plot_points'] ?? [], + 'themes' => $responseData['elements']['themes'] ?? [], + ], + 'story_metadata' => [ + 'word_count' => $responseData['metadata']['word_count'] ?? 0, + 'reading_time' => $responseData['metadata']['reading_time'] ?? 0.0, + 'complexity_level' => $responseData['metadata']['complexity'] ?? '', + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'story_generation' => [ + 'image_url' => $imageUrl, + 'story_style' => $storyStyle, + 'story' => '', + 'story_elements' => [ + 'characters' => [], + 'setting' => '', + 'plot_points' => [], + 'themes' => [], + ], + 'story_metadata' => [ + 'word_count' => 0, + 'reading_time' => 0.0, + 'complexity_level' => '', + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Answer questions about scenes. + * + * @param string $imageUrl URL or base64 encoded image + * @param string $question Question about the scene + * @param array $options Question answering options + * + * @return array{ + * success: bool, + * question_answering: array{ + * image_url: string, + * question: string, + * answer: string, + * confidence: float, + * supporting_evidence: array, + * answer_type: string, + * related_questions: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function answerQuestions( + string $imageUrl, + string $question, + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'question' => $question, + 'options' => array_merge([ + 'include_evidence' => $options['include_evidence'] ?? true, + 'confidence_threshold' => $options['confidence_threshold'] ?? 0.5, + 'answer_detail_level' => $options['detail_level'] ?? 'medium', + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/answer-question", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + + return [ + 'success' => true, + 'question_answering' => [ + 'image_url' => $imageUrl, + 'question' => $question, + 'answer' => $responseData['answer'] ?? '', + 'confidence' => $responseData['confidence'] ?? 0.0, + 'supporting_evidence' => $responseData['evidence'] ?? [], + 'answer_type' => $responseData['answer_type'] ?? '', + 'related_questions' => $responseData['related_questions'] ?? [], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'question_answering' => [ + 'image_url' => $imageUrl, + 'question' => $question, + 'answer' => '', + 'confidence' => 0.0, + 'supporting_evidence' => [], + 'answer_type' => '', + 'related_questions' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Helper methods. + */ + private function countCategories(array $objects): array + { + $categories = []; + foreach ($objects as $object) { + $category = $object['category'] ?? 'unknown'; + $categories[$category] = ($categories[$category] ?? 0) + 1; + } + + return $categories; + } +} diff --git a/src/agent/src/Toolbox/Tool/SearchApiClient.php b/src/agent/src/Toolbox/Tool/SearchApiClient.php new file mode 100644 index 000000000..65b4eeefb --- /dev/null +++ b/src/agent/src/Toolbox/Tool/SearchApiClient.php @@ -0,0 +1,727 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('searchapi_search', 'Tool that searches using SearchAPI')] +#[AsTool('searchapi_search_news', 'Tool that searches news using SearchAPI', method: 'searchNews')] +#[AsTool('searchapi_search_images', 'Tool that searches images using SearchAPI', method: 'searchImages')] +#[AsTool('searchapi_search_shopping', 'Tool that searches shopping products using SearchAPI', method: 'searchShopping')] +#[AsTool('searchapi_search_videos', 'Tool that searches videos using SearchAPI', method: 'searchVideos')] +#[AsTool('searchapi_search_places', 'Tool that searches places using SearchAPI', method: 'searchPlaces')] +#[AsTool('searchapi_get_serp', 'Tool that gets SERP data using SearchAPI', method: 'getSerp')] +#[AsTool('searchapi_get_locations', 'Tool that gets available locations using SearchAPI', method: 'getLocations')] +final readonly class SearchApiClient +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://www.searchapi.io/api/v1', + private array $options = [], + ) { + } + + /** + * Search using SearchAPI. + * + * @param string $query Search query + * @param string $engine Search engine (google, bing, yahoo, duckduckgo) + * @param string $country Country code (us, uk, de, fr, etc.) + * @param string $language Language code (en, es, fr, de, etc.) + * @param int $num Number of results + * @param int $start Start index + * @param string $device Device type (desktop, mobile, tablet) + * @param array $extraParams Extra parameters + * + * @return array{ + * success: bool, + * results: array, + * searchInformation: array{ + * totalResults: string, + * searchTime: float, + * query: string, + * }, + * pagination: array{ + * current: int, + * next: string, + * otherPages: array, + * }, + * relatedSearches: array, + * error: string, + * } + */ + public function __invoke( + string $query, + string $engine = 'google', + string $country = 'us', + string $language = 'en', + int $num = 10, + int $start = 0, + string $device = 'desktop', + array $extraParams = [], + ): array { + try { + $params = [ + 'api_key' => $this->apiKey, + 'q' => $query, + 'engine' => $engine, + 'country' => $country, + 'language' => $language, + 'num' => max(1, min($num, 100)), + 'start' => max(0, $start), + 'device' => $device, + ]; + + $params = array_merge($params, $extraParams); + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'results' => array_map(fn ($result) => [ + 'title' => $result['title'] ?? '', + 'link' => $result['link'] ?? '', + 'snippet' => $result['snippet'] ?? '', + 'position' => $result['position'] ?? 0, + 'source' => $result['source'] ?? '', + ], $data['organic_results'] ?? []), + 'searchInformation' => [ + 'totalResults' => $data['search_information']['total_results'] ?? '0', + 'searchTime' => $data['search_information']['time_taken_displayed'] ?? 0.0, + 'query' => $data['search_information']['query_displayed'] ?? $query, + ], + 'pagination' => [ + 'current' => $data['pagination']['current'] ?? 1, + 'next' => $data['pagination']['next'] ?? '', + 'otherPages' => $data['pagination']['other_pages'] ?? [], + ], + 'relatedSearches' => array_map(fn ($search) => $search['query'], $data['related_searches'] ?? []), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'results' => [], + 'searchInformation' => ['totalResults' => '0', 'searchTime' => 0.0, 'query' => $query], + 'pagination' => ['current' => 1, 'next' => '', 'otherPages' => []], + 'relatedSearches' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search news using SearchAPI. + * + * @param string $query News search query + * @param string $country Country code + * @param string $language Language code + * @param int $num Number of results + * @param int $start Start index + * @param string $timeframe Time frame (d, w, m, y) + * @param string $device Device type + * + * @return array{ + * success: bool, + * news: array, + * searchInformation: array{ + * totalResults: string, + * searchTime: float, + * query: string, + * }, + * error: string, + * } + */ + public function searchNews( + string $query, + string $country = 'us', + string $language = 'en', + int $num = 10, + int $start = 0, + string $timeframe = 'w', + string $device = 'desktop', + ): array { + try { + $params = [ + 'api_key' => $this->apiKey, + 'q' => $query, + 'engine' => 'google', + 'tbm' => 'nws', + 'country' => $country, + 'language' => $language, + 'num' => max(1, min($num, 100)), + 'start' => max(0, $start), + 'device' => $device, + 'tbs' => "qdr:{$timeframe}", + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'news' => array_map(fn ($article) => [ + 'title' => $article['title'] ?? '', + 'link' => $article['link'] ?? '', + 'snippet' => $article['snippet'] ?? '', + 'date' => $article['date'] ?? '', + 'source' => $article['source'] ?? '', + 'position' => $article['position'] ?? 0, + 'thumbnail' => $article['thumbnail'] ?? '', + ], $data['news_results'] ?? []), + 'searchInformation' => [ + 'totalResults' => $data['search_information']['total_results'] ?? '0', + 'searchTime' => $data['search_information']['time_taken_displayed'] ?? 0.0, + 'query' => $data['search_information']['query_displayed'] ?? $query, + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'news' => [], + 'searchInformation' => ['totalResults' => '0', 'searchTime' => 0.0, 'query' => $query], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search images using SearchAPI. + * + * @param string $query Image search query + * @param string $country Country code + * @param string $language Language code + * @param int $num Number of results + * @param int $start Start index + * @param string $color Color filter (red, orange, yellow, green, teal, blue, purple, pink, white, gray, black, brown) + * @param string $size Size filter (large, medium, icon) + * @param string $type Type filter (photo, clipart, lineart, animated) + * @param string $device Device type + * + * @return array{ + * success: bool, + * images: array, + * searchInformation: array{ + * totalResults: string, + * searchTime: float, + * query: string, + * }, + * error: string, + * } + */ + public function searchImages( + string $query, + string $country = 'us', + string $language = 'en', + int $num = 20, + int $start = 0, + string $color = '', + string $size = '', + string $type = '', + string $device = 'desktop', + ): array { + try { + $params = [ + 'api_key' => $this->apiKey, + 'q' => $query, + 'engine' => 'google', + 'tbm' => 'isch', + 'country' => $country, + 'language' => $language, + 'num' => max(1, min($num, 100)), + 'start' => max(0, $start), + 'device' => $device, + ]; + + if ($color) { + $params['tbs'] = "ic:specific,isc:{$color}"; + } + + if ($size) { + $params['isz'] = $size; + } + + if ($type) { + $params['itp'] = $type; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'images' => array_map(fn ($image) => [ + 'title' => $image['title'] ?? '', + 'link' => $image['link'] ?? '', + 'image' => $image['image'] ?? '', + 'source' => $image['source'] ?? '', + 'thumbnail' => $image['thumbnail'] ?? '', + 'position' => $image['position'] ?? 0, + 'originalWidth' => $image['original_width'] ?? 0, + 'originalHeight' => $image['original_height'] ?? 0, + ], $data['images_results'] ?? []), + 'searchInformation' => [ + 'totalResults' => $data['search_information']['total_results'] ?? '0', + 'searchTime' => $data['search_information']['time_taken_displayed'] ?? 0.0, + 'query' => $data['search_information']['query_displayed'] ?? $query, + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'images' => [], + 'searchInformation' => ['totalResults' => '0', 'searchTime' => 0.0, 'query' => $query], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search shopping products using SearchAPI. + * + * @param string $query Shopping search query + * @param string $country Country code + * @param string $language Language code + * @param int $num Number of results + * @param int $start Start index + * @param string $sort Sort order (price_asc, price_desc, rating, relevance) + * @param string $device Device type + * + * @return array{ + * success: bool, + * shopping: array, + * searchInformation: array{ + * totalResults: string, + * searchTime: float, + * query: string, + * }, + * error: string, + * } + */ + public function searchShopping( + string $query, + string $country = 'us', + string $language = 'en', + int $num = 10, + int $start = 0, + string $sort = 'relevance', + string $device = 'desktop', + ): array { + try { + $params = [ + 'api_key' => $this->apiKey, + 'q' => $query, + 'engine' => 'google', + 'tbm' => 'shop', + 'country' => $country, + 'language' => $language, + 'num' => max(1, min($num, 100)), + 'start' => max(0, $start), + 'device' => $device, + ]; + + if ($sort) { + $params['tbs'] = 'sbd:1'; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'shopping' => array_map(fn ($product) => [ + 'title' => $product['title'] ?? '', + 'link' => $product['link'] ?? '', + 'price' => $product['price'] ?? '', + 'rating' => $product['rating'] ?? 0.0, + 'reviews' => $product['reviews'] ?? 0, + 'source' => $product['source'] ?? '', + 'thumbnail' => $product['thumbnail'] ?? '', + 'position' => $product['position'] ?? 0, + 'delivery' => $product['delivery'] ?? '', + ], $data['shopping_results'] ?? []), + 'searchInformation' => [ + 'totalResults' => $data['search_information']['total_results'] ?? '0', + 'searchTime' => $data['search_information']['time_taken_displayed'] ?? 0.0, + 'query' => $data['search_information']['query_displayed'] ?? $query, + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'shopping' => [], + 'searchInformation' => ['totalResults' => '0', 'searchTime' => 0.0, 'query' => $query], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search videos using SearchAPI. + * + * @param string $query Video search query + * @param string $country Country code + * @param string $language Language code + * @param int $num Number of results + * @param int $start Start index + * @param string $duration Duration filter (short, medium, long) + * @param string $device Device type + * + * @return array{ + * success: bool, + * videos: array, + * searchInformation: array{ + * totalResults: string, + * searchTime: float, + * query: string, + * }, + * error: string, + * } + */ + public function searchVideos( + string $query, + string $country = 'us', + string $language = 'en', + int $num = 10, + int $start = 0, + string $duration = '', + string $device = 'desktop', + ): array { + try { + $params = [ + 'api_key' => $this->apiKey, + 'q' => $query, + 'engine' => 'google', + 'tbm' => 'vid', + 'country' => $country, + 'language' => $language, + 'num' => max(1, min($num, 100)), + 'start' => max(0, $start), + 'device' => $device, + ]; + + if ($duration) { + $params['tbs'] = "dur:{$duration}"; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'videos' => array_map(fn ($video) => [ + 'title' => $video['title'] ?? '', + 'link' => $video['link'] ?? '', + 'snippet' => $video['snippet'] ?? '', + 'date' => $video['date'] ?? '', + 'duration' => $video['duration'] ?? '', + 'views' => $video['views'] ?? '', + 'channel' => $video['channel'] ?? '', + 'thumbnail' => $video['thumbnail'] ?? '', + 'position' => $video['position'] ?? 0, + ], $data['video_results'] ?? []), + 'searchInformation' => [ + 'totalResults' => $data['search_information']['total_results'] ?? '0', + 'searchTime' => $data['search_information']['time_taken_displayed'] ?? 0.0, + 'query' => $data['search_information']['query_displayed'] ?? $query, + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'videos' => [], + 'searchInformation' => ['totalResults' => '0', 'searchTime' => 0.0, 'query' => $query], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search places using SearchAPI. + * + * @param string $query Place search query + * @param string $country Country code + * @param string $language Language code + * @param int $num Number of results + * @param int $start Start index + * @param string $device Device type + * + * @return array{ + * success: bool, + * places: array, + * searchInformation: array{ + * totalResults: string, + * searchTime: float, + * query: string, + * }, + * error: string, + * } + */ + public function searchPlaces( + string $query, + string $country = 'us', + string $language = 'en', + int $num = 10, + int $start = 0, + string $device = 'desktop', + ): array { + try { + $params = [ + 'api_key' => $this->apiKey, + 'q' => $query, + 'engine' => 'google', + 'tbm' => 'lcl', + 'country' => $country, + 'language' => $language, + 'num' => max(1, min($num, 100)), + 'start' => max(0, $start), + 'device' => $device, + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'places' => array_map(fn ($place) => [ + 'title' => $place['title'] ?? '', + 'link' => $place['link'] ?? '', + 'snippet' => $place['snippet'] ?? '', + 'address' => $place['address'] ?? '', + 'phone' => $place['phone'] ?? '', + 'rating' => $place['rating'] ?? 0.0, + 'reviews' => $place['reviews'] ?? 0, + 'price' => $place['price'] ?? '', + 'hours' => $place['hours'] ?? '', + 'position' => $place['position'] ?? 0, + ], $data['local_results'] ?? []), + 'searchInformation' => [ + 'totalResults' => $data['search_information']['total_results'] ?? '0', + 'searchTime' => $data['search_information']['time_taken_displayed'] ?? 0.0, + 'query' => $data['search_information']['query_displayed'] ?? $query, + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'places' => [], + 'searchInformation' => ['totalResults' => '0', 'searchTime' => 0.0, 'query' => $query], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get SERP data using SearchAPI. + * + * @param string $query Search query + * @param string $engine Search engine + * @param string $country Country code + * @param string $language Language code + * @param int $num Number of results + * @param int $start Start index + * @param string $device Device type + * + * @return array{ + * success: bool, + * serp: array, + * organicResults: array>, + * paidResults: array>, + * featuredSnippet: array, + * knowledgeGraph: array, + * searchInformation: array, + * error: string, + * } + */ + public function getSerp( + string $query, + string $engine = 'google', + string $country = 'us', + string $language = 'en', + int $num = 10, + int $start = 0, + string $device = 'desktop', + ): array { + try { + $params = [ + 'api_key' => $this->apiKey, + 'q' => $query, + 'engine' => $engine, + 'country' => $country, + 'language' => $language, + 'num' => max(1, min($num, 100)), + 'start' => max(0, $start), + 'device' => $device, + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'serp' => $data, + 'organicResults' => $data['organic_results'] ?? [], + 'paidResults' => $data['paid_results'] ?? [], + 'featuredSnippet' => $data['featured_snippet'] ?? [], + 'knowledgeGraph' => $data['knowledge_graph'] ?? [], + 'searchInformation' => $data['search_information'] ?? [], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'serp' => [], + 'organicResults' => [], + 'paidResults' => [], + 'featuredSnippet' => [], + 'knowledgeGraph' => [], + 'searchInformation' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get available locations using SearchAPI. + * + * @return array{ + * success: bool, + * locations: array, + * error: string, + * } + */ + public function getLocations(): array + { + try { + $response = $this->httpClient->request('GET', "{$this->baseUrl}/locations", [ + 'query' => array_merge($this->options, ['api_key' => $this->apiKey]), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'locations' => array_map(fn ($location) => [ + 'country' => $location['country'] ?? '', + 'countryCode' => $location['country_code'] ?? '', + 'language' => $location['language'] ?? '', + 'languageCode' => $location['language_code'] ?? '', + 'currency' => $location['currency'] ?? '', + 'currencyCode' => $location['currency_code'] ?? '', + ], $data['locations'] ?? []), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'locations' => [], + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/SearxSearch.php b/src/agent/src/Toolbox/Tool/SearxSearch.php new file mode 100644 index 000000000..b50aaef75 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/SearxSearch.php @@ -0,0 +1,724 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('searx_search', 'Tool that searches using SearX meta-search engine')] +#[AsTool('searx_search_images', 'Tool that searches images using SearX', method: 'searchImages')] +#[AsTool('searx_search_news', 'Tool that searches news using SearX', method: 'searchNews')] +#[AsTool('searx_search_videos', 'Tool that searches videos using SearX', method: 'searchVideos')] +#[AsTool('searx_search_files', 'Tool that searches files using SearX', method: 'searchFiles')] +#[AsTool('searx_search_maps', 'Tool that searches maps using SearX', method: 'searchMaps')] +#[AsTool('searx_search_music', 'Tool that searches music using SearX', method: 'searchMusic')] +#[AsTool('searx_search_it', 'Tool that searches IT-related content using SearX', method: 'searchIT')] +final readonly class SearxSearch +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $baseUrl = 'https://searx.be', + private array $options = [], + ) { + } + + /** + * Search using SearX meta-search engine. + * + * @param string $query Search query + * @param array $categories Search categories (general, images, videos, news, files, maps, music, it) + * @param string $language Search language + * @param string $timeRange Time range filter (day, week, month, year) + * @param int $page Page number + * + * @return array{ + * success: bool, + * results: array, + * template: string, + * engines: array, + * positions: array, + * score: float, + * category: string, + * }>, + * answers: array, + * corrections: array, + * infoboxes: array, + * }>, + * suggestions: array, + * unresponsiveEngines: array, + * query: string, + * numberOfResults: int, + * error: string, + * } + */ + public function __invoke( + string $query, + array $categories = ['general'], + string $language = 'all', + string $timeRange = '', + int $page = 0, + ): array { + try { + $params = [ + 'q' => $query, + 'categories' => implode(',', $categories), + 'language' => $language, + 'pageno' => $page, + ]; + + if ($timeRange) { + $params['time_range'] = $timeRange; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'results' => array_map(fn ($result) => [ + 'title' => $result['title'] ?? '', + 'url' => $result['url'] ?? '', + 'content' => $result['content'] ?? '', + 'engine' => $result['engine'] ?? '', + 'parsedUrl' => $result['parsed_url'] ?? [], + 'template' => $result['template'] ?? '', + 'engines' => $result['engines'] ?? [], + 'positions' => $result['positions'] ?? [], + 'score' => $result['score'] ?? 0.0, + 'category' => $result['category'] ?? 'general', + ], $data['results'] ?? []), + 'answers' => $data['answers'] ?? [], + 'corrections' => $data['corrections'] ?? [], + 'infoboxes' => array_map(fn ($infobox) => [ + 'infobox' => $infobox['infobox'] ?? '', + 'content' => $infobox['content'] ?? '', + 'engine' => $infobox['engine'] ?? '', + 'urls' => array_map(fn ($url) => [ + 'title' => $url['title'] ?? '', + 'url' => $url['url'] ?? '', + ], $infobox['urls'] ?? []), + ], $data['infoboxes'] ?? []), + 'suggestions' => $data['suggestions'] ?? [], + 'unresponsiveEngines' => $data['unresponsive_engines'] ?? [], + 'query' => $query, + 'numberOfResults' => $data['number_of_results'] ?? 0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'results' => [], + 'answers' => [], + 'corrections' => [], + 'infoboxes' => [], + 'suggestions' => [], + 'unresponsiveEngines' => [], + 'query' => $query, + 'numberOfResults' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search images using SearX. + * + * @param string $query Image search query + * @param string $language Search language + * @param string $timeRange Time range filter + * @param int $page Page number + * + * @return array{ + * success: bool, + * images: array, + * positions: array, + * content: string, + * author: string, + * source: string, + * thumbnail: string, + * imgFormat: string, + * resolution: string, + * parsedUrl: array, + * }>, + * query: string, + * numberOfResults: int, + * error: string, + * } + */ + public function searchImages( + string $query, + string $language = 'all', + string $timeRange = '', + int $page = 0, + ): array { + try { + $params = [ + 'q' => $query, + 'categories' => 'images', + 'language' => $language, + 'pageno' => $page, + ]; + + if ($timeRange) { + $params['time_range'] = $timeRange; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'images' => array_map(fn ($image) => [ + 'title' => $image['title'] ?? '', + 'url' => $image['url'] ?? '', + 'imgSrc' => $image['img_src'] ?? '', + 'thumbnailSrc' => $image['thumbnail_src'] ?? '', + 'template' => $image['template'] ?? '', + 'engine' => $image['engine'] ?? '', + 'engines' => $image['engines'] ?? [], + 'positions' => $image['positions'] ?? [], + 'content' => $image['content'] ?? '', + 'author' => $image['author'] ?? '', + 'source' => $image['source'] ?? '', + 'thumbnail' => $image['thumbnail'] ?? '', + 'imgFormat' => $image['img_format'] ?? '', + 'resolution' => $image['resolution'] ?? '', + 'parsedUrl' => $image['parsed_url'] ?? [], + ], $data['results'] ?? []), + 'query' => $query, + 'numberOfResults' => $data['number_of_results'] ?? 0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'images' => [], + 'query' => $query, + 'numberOfResults' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search news using SearX. + * + * @param string $query News search query + * @param string $language Search language + * @param string $timeRange Time range filter + * @param int $page Page number + * + * @return array{ + * success: bool, + * news: array, + * positions: array, + * parsedUrl: array, + * }>, + * query: string, + * numberOfResults: int, + * error: string, + * } + */ + public function searchNews( + string $query, + string $language = 'all', + string $timeRange = '', + int $page = 0, + ): array { + try { + $params = [ + 'q' => $query, + 'categories' => 'news', + 'language' => $language, + 'pageno' => $page, + ]; + + if ($timeRange) { + $params['time_range'] = $timeRange; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'news' => array_map(fn ($article) => [ + 'title' => $article['title'] ?? '', + 'url' => $article['url'] ?? '', + 'content' => $article['content'] ?? '', + 'publishedDate' => $article['publishedDate'] ?? '', + 'engine' => $article['engine'] ?? '', + 'template' => $article['template'] ?? '', + 'engines' => $article['engines'] ?? [], + 'positions' => $article['positions'] ?? [], + 'parsedUrl' => $article['parsed_url'] ?? [], + ], $data['results'] ?? []), + 'query' => $query, + 'numberOfResults' => $data['number_of_results'] ?? 0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'news' => [], + 'query' => $query, + 'numberOfResults' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search videos using SearX. + * + * @param string $query Video search query + * @param string $language Search language + * @param string $timeRange Time range filter + * @param int $page Page number + * + * @return array{ + * success: bool, + * videos: array, + * positions: array, + * parsedUrl: array, + * }>, + * query: string, + * numberOfResults: int, + * error: string, + * } + */ + public function searchVideos( + string $query, + string $language = 'all', + string $timeRange = '', + int $page = 0, + ): array { + try { + $params = [ + 'q' => $query, + 'categories' => 'videos', + 'language' => $language, + 'pageno' => $page, + ]; + + if ($timeRange) { + $params['time_range'] = $timeRange; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'videos' => array_map(fn ($video) => [ + 'title' => $video['title'] ?? '', + 'url' => $video['url'] ?? '', + 'content' => $video['content'] ?? '', + 'thumbnail' => $video['thumbnail'] ?? '', + 'publishedDate' => $video['publishedDate'] ?? '', + 'length' => $video['length'] ?? '', + 'engine' => $video['engine'] ?? '', + 'template' => $video['template'] ?? '', + 'engines' => $video['engines'] ?? [], + 'positions' => $video['positions'] ?? [], + 'parsedUrl' => $video['parsed_url'] ?? [], + ], $data['results'] ?? []), + 'query' => $query, + 'numberOfResults' => $data['number_of_results'] ?? 0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'videos' => [], + 'query' => $query, + 'numberOfResults' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search files using SearX. + * + * @param string $query File search query + * @param string $language Search language + * @param string $timeRange Time range filter + * @param int $page Page number + * + * @return array{ + * success: bool, + * files: array, + * positions: array, + * parsedUrl: array, + * size: string, + * type: string, + * }>, + * query: string, + * numberOfResults: int, + * error: string, + * } + */ + public function searchFiles( + string $query, + string $language = 'all', + string $timeRange = '', + int $page = 0, + ): array { + try { + $params = [ + 'q' => $query, + 'categories' => 'files', + 'language' => $language, + 'pageno' => $page, + ]; + + if ($timeRange) { + $params['time_range'] = $timeRange; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'files' => array_map(fn ($file) => [ + 'title' => $file['title'] ?? '', + 'url' => $file['url'] ?? '', + 'content' => $file['content'] ?? '', + 'template' => $file['template'] ?? '', + 'engine' => $file['engine'] ?? '', + 'engines' => $file['engines'] ?? [], + 'positions' => $file['positions'] ?? [], + 'parsedUrl' => $file['parsed_url'] ?? [], + 'size' => $file['size'] ?? '', + 'type' => $file['type'] ?? '', + ], $data['results'] ?? []), + 'query' => $query, + 'numberOfResults' => $data['number_of_results'] ?? 0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'files' => [], + 'query' => $query, + 'numberOfResults' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search maps using SearX. + * + * @param string $query Map search query + * @param string $language Search language + * @param int $page Page number + * + * @return array{ + * success: bool, + * maps: array, + * positions: array, + * parsedUrl: array, + * }>, + * query: string, + * numberOfResults: int, + * error: string, + * } + */ + public function searchMaps( + string $query, + string $language = 'all', + int $page = 0, + ): array { + try { + $params = [ + 'q' => $query, + 'categories' => 'maps', + 'language' => $language, + 'pageno' => $page, + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'maps' => array_map(fn ($map) => [ + 'title' => $map['title'] ?? '', + 'url' => $map['url'] ?? '', + 'content' => $map['content'] ?? '', + 'address' => $map['address'] ?? '', + 'latitude' => $map['latitude'] ?? 0.0, + 'longitude' => $map['longitude'] ?? 0.0, + 'engine' => $map['engine'] ?? '', + 'template' => $map['template'] ?? '', + 'engines' => $map['engines'] ?? [], + 'positions' => $map['positions'] ?? [], + 'parsedUrl' => $map['parsed_url'] ?? [], + ], $data['results'] ?? []), + 'query' => $query, + 'numberOfResults' => $data['number_of_results'] ?? 0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'maps' => [], + 'query' => $query, + 'numberOfResults' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search music using SearX. + * + * @param string $query Music search query + * @param string $language Search language + * @param string $timeRange Time range filter + * @param int $page Page number + * + * @return array{ + * success: bool, + * music: array, + * positions: array, + * parsedUrl: array, + * }>, + * query: string, + * numberOfResults: int, + * error: string, + * } + */ + public function searchMusic( + string $query, + string $language = 'all', + string $timeRange = '', + int $page = 0, + ): array { + try { + $params = [ + 'q' => $query, + 'categories' => 'music', + 'language' => $language, + 'pageno' => $page, + ]; + + if ($timeRange) { + $params['time_range'] = $timeRange; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'music' => array_map(fn ($track) => [ + 'title' => $track['title'] ?? '', + 'url' => $track['url'] ?? '', + 'content' => $track['content'] ?? '', + 'thumbnail' => $track['thumbnail'] ?? '', + 'publishedDate' => $track['publishedDate'] ?? '', + 'length' => $track['length'] ?? '', + 'artist' => $track['artist'] ?? '', + 'album' => $track['album'] ?? '', + 'engine' => $track['engine'] ?? '', + 'template' => $track['template'] ?? '', + 'engines' => $track['engines'] ?? [], + 'positions' => $track['positions'] ?? [], + 'parsedUrl' => $track['parsed_url'] ?? [], + ], $data['results'] ?? []), + 'query' => $query, + 'numberOfResults' => $data['number_of_results'] ?? 0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'music' => [], + 'query' => $query, + 'numberOfResults' => 0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search IT-related content using SearX. + * + * @param string $query IT search query + * @param string $language Search language + * @param string $timeRange Time range filter + * @param int $page Page number + * + * @return array{ + * success: bool, + * itResults: array, + * positions: array, + * parsedUrl: array, + * tags: array, + * }>, + * query: string, + * numberOfResults: int, + * error: string, + * } + */ + public function searchIT( + string $query, + string $language = 'all', + string $timeRange = '', + int $page = 0, + ): array { + try { + $params = [ + 'q' => $query, + 'categories' => 'it', + 'language' => $language, + 'pageno' => $page, + ]; + + if ($timeRange) { + $params['time_range'] = $timeRange; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/search", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'itResults' => array_map(fn ($result) => [ + 'title' => $result['title'] ?? '', + 'url' => $result['url'] ?? '', + 'content' => $result['content'] ?? '', + 'publishedDate' => $result['publishedDate'] ?? '', + 'engine' => $result['engine'] ?? '', + 'template' => $result['template'] ?? '', + 'engines' => $result['engines'] ?? [], + 'positions' => $result['positions'] ?? [], + 'parsedUrl' => $result['parsed_url'] ?? [], + 'tags' => $result['tags'] ?? [], + ], $data['results'] ?? []), + 'query' => $query, + 'numberOfResults' => $data['number_of_results'] ?? 0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'itResults' => [], + 'query' => $query, + 'numberOfResults' => 0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/SemanticScholar.php b/src/agent/src/Toolbox/Tool/SemanticScholar.php new file mode 100644 index 000000000..3399902e0 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/SemanticScholar.php @@ -0,0 +1,538 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('semantic_scholar_search', 'Tool that searches academic papers using Semantic Scholar')] +#[AsTool('semantic_scholar_get_paper', 'Tool that gets paper details from Semantic Scholar', method: 'getPaper')] +#[AsTool('semantic_scholar_get_author', 'Tool that gets author details from Semantic Scholar', method: 'getAuthor')] +#[AsTool('semantic_scholar_get_citations', 'Tool that gets paper citations from Semantic Scholar', method: 'getCitations')] +#[AsTool('semantic_scholar_get_references', 'Tool that gets paper references from Semantic Scholar', method: 'getReferences')] +#[AsTool('semantic_scholar_get_recommendations', 'Tool that gets paper recommendations from Semantic Scholar', method: 'getRecommendations')] +final readonly class SemanticScholar +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $baseUrl = 'https://api.semanticscholar.org/graph/v1', + private string $apiKey = '', + private array $options = [], + ) { + } + + /** + * Search academic papers using Semantic Scholar. + * + * @param string $query Search query + * @param int $limit Number of results + * @param int $offset Offset for pagination + * @param string $fields Fields to return + * @param string $sort Sort order (relevance, year, citations) + * @param string $yearMin Minimum publication year + * @param string $yearMax Maximum publication year + * @param array $venueFilter Venue filter + * @param array $authorFilter Author filter + * + * @return array{ + * total: int, + * offset: int, + * next: int, + * data: array, + * url: string, + * title: string, + * abstract: string|null, + * venue: string, + * year: int, + * referenceCount: int, + * citationCount: int, + * influentialCitationCount: int, + * isOpenAccess: bool, + * openAccessPdf: array{ + * url: string, + * status: string, + * }|null, + * fieldsOfStudy: array, + * authors: array, + * citations: array, + * references: array, + * }>, + * } + */ + public function __invoke( + string $query, + int $limit = 10, + int $offset = 0, + string $fields = 'paperId,title,abstract,venue,year,referenceCount,citationCount,authors', + string $sort = 'relevance', + string $yearMin = '', + string $yearMax = '', + array $venueFilter = [], + array $authorFilter = [], + ): array { + try { + $params = [ + 'query' => $query, + 'limit' => min(max($limit, 1), 100), + 'offset' => max($offset, 0), + 'fields' => $fields, + 'sort' => $sort, + ]; + + if ($yearMin) { + $params['year'] = $yearMin; + if ($yearMax) { + $params['year'] = "{$yearMin}:{$yearMax}"; + } + } + + if (!empty($venueFilter)) { + $params['venue'] = implode(',', $venueFilter); + } + + if (!empty($authorFilter)) { + $params['authors'] = implode(',', $authorFilter); + } + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['x-api-key'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/paper/search", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'total' => $data['total'] ?? 0, + 'offset' => $data['offset'] ?? $offset, + 'next' => $data['next'] ?? 0, + 'data' => array_map(fn ($paper) => [ + 'paperId' => $paper['paperId'], + 'externalIds' => $paper['externalIds'] ?? [], + 'url' => $paper['url'] ?? '', + 'title' => $paper['title'], + 'abstract' => $paper['abstract'] ?? null, + 'venue' => $paper['venue'] ?? '', + 'year' => $paper['year'] ?? 0, + 'referenceCount' => $paper['referenceCount'] ?? 0, + 'citationCount' => $paper['citationCount'] ?? 0, + 'influentialCitationCount' => $paper['influentialCitationCount'] ?? 0, + 'isOpenAccess' => $paper['isOpenAccess'] ?? false, + 'openAccessPdf' => $paper['openAccessPdf'] ? [ + 'url' => $paper['openAccessPdf']['url'], + 'status' => $paper['openAccessPdf']['status'], + ] : null, + 'fieldsOfStudy' => $paper['fieldsOfStudy'] ?? [], + 'authors' => array_map(fn ($author) => [ + 'authorId' => $author['authorId'], + 'name' => $author['name'], + ], $paper['authors'] ?? []), + 'citations' => array_map(fn ($citation) => [ + 'paperId' => $citation['paperId'], + 'title' => $citation['title'], + 'year' => $citation['year'] ?? 0, + ], $paper['citations'] ?? []), + 'references' => array_map(fn ($reference) => [ + 'paperId' => $reference['paperId'], + 'title' => $reference['title'], + 'year' => $reference['year'] ?? 0, + ], $paper['references'] ?? []), + ], $data['data'] ?? []), + ]; + } catch (\Exception $e) { + return [ + 'total' => 0, + 'offset' => $offset, + 'next' => 0, + 'data' => [], + ]; + } + } + + /** + * Get paper details from Semantic Scholar. + * + * @param string $paperId Paper ID + * @param string $fields Fields to return + * + * @return array{ + * paperId: string, + * externalIds: array, + * url: string, + * title: string, + * abstract: string|null, + * venue: string, + * year: int, + * referenceCount: int, + * citationCount: int, + * influentialCitationCount: int, + * isOpenAccess: bool, + * openAccessPdf: array{ + * url: string, + * status: string, + * }|null, + * fieldsOfStudy: array, + * authors: array, + * tldr: array{ + * model: string, + * text: string, + * }|null, + * }|string + */ + public function getPaper( + string $paperId, + string $fields = 'paperId,title,abstract,venue,year,referenceCount,citationCount,authors,tldr', + ): array|string { + try { + $params = [ + 'fields' => $fields, + ]; + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['x-api-key'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/paper/{$paperId}", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting paper: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'paperId' => $data['paperId'], + 'externalIds' => $data['externalIds'] ?? [], + 'url' => $data['url'] ?? '', + 'title' => $data['title'], + 'abstract' => $data['abstract'] ?? null, + 'venue' => $data['venue'] ?? '', + 'year' => $data['year'] ?? 0, + 'referenceCount' => $data['referenceCount'] ?? 0, + 'citationCount' => $data['citationCount'] ?? 0, + 'influentialCitationCount' => $data['influentialCitationCount'] ?? 0, + 'isOpenAccess' => $data['isOpenAccess'] ?? false, + 'openAccessPdf' => $data['openAccessPdf'] ? [ + 'url' => $data['openAccessPdf']['url'], + 'status' => $data['openAccessPdf']['status'], + ] : null, + 'fieldsOfStudy' => $data['fieldsOfStudy'] ?? [], + 'authors' => array_map(fn ($author) => [ + 'authorId' => $author['authorId'], + 'name' => $author['name'], + ], $data['authors'] ?? []), + 'tldr' => $data['tldr'] ? [ + 'model' => $data['tldr']['model'], + 'text' => $data['tldr']['text'], + ] : null, + ]; + } catch (\Exception $e) { + return 'Error getting paper: '.$e->getMessage(); + } + } + + /** + * Get author details from Semantic Scholar. + * + * @param string $authorId Author ID + * @param string $fields Fields to return + * + * @return array{ + * authorId: string, + * name: string, + * aliases: array, + * url: string, + * papers: array, + * }|string + */ + public function getAuthor( + string $authorId, + string $fields = 'authorId,name,aliases,url,papers', + ): array|string { + try { + $params = [ + 'fields' => $fields, + ]; + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['x-api-key'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/author/{$authorId}", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting author: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'authorId' => $data['authorId'], + 'name' => $data['name'], + 'aliases' => $data['aliases'] ?? [], + 'url' => $data['url'] ?? '', + 'papers' => array_map(fn ($paper) => [ + 'paperId' => $paper['paperId'], + 'title' => $paper['title'], + 'year' => $paper['year'] ?? 0, + ], $data['papers'] ?? []), + ]; + } catch (\Exception $e) { + return 'Error getting author: '.$e->getMessage(); + } + } + + /** + * Get paper citations from Semantic Scholar. + * + * @param string $paperId Paper ID + * @param int $limit Number of citations + * @param int $offset Offset for pagination + * + * @return array{ + * paperId: string, + * citations: array, + * next: int, + * } + */ + public function getCitations( + string $paperId, + int $limit = 10, + int $offset = 0, + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), + 'offset' => max($offset, 0), + 'fields' => 'paperId,title,year,venue,citationCount,influentialCitationCount', + ]; + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['x-api-key'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/paper/{$paperId}/citations", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'paperId' => $paperId, + 'citations' => array_map(fn ($citation) => [ + 'paperId' => $citation['paperId'], + 'title' => $citation['title'], + 'year' => $citation['year'] ?? 0, + 'venue' => $citation['venue'] ?? '', + 'citationCount' => $citation['citationCount'] ?? 0, + 'influentialCitationCount' => $citation['influentialCitationCount'] ?? 0, + ], $data['data'] ?? []), + 'next' => $data['next'] ?? 0, + ]; + } catch (\Exception $e) { + return [ + 'paperId' => $paperId, + 'citations' => [], + 'next' => 0, + ]; + } + } + + /** + * Get paper references from Semantic Scholar. + * + * @param string $paperId Paper ID + * @param int $limit Number of references + * @param int $offset Offset for pagination + * + * @return array{ + * paperId: string, + * references: array, + * next: int, + * } + */ + public function getReferences( + string $paperId, + int $limit = 10, + int $offset = 0, + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), + 'offset' => max($offset, 0), + 'fields' => 'paperId,title,year,venue,citationCount,influentialCitationCount', + ]; + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['x-api-key'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/paper/{$paperId}/references", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'paperId' => $paperId, + 'references' => array_map(fn ($reference) => [ + 'paperId' => $reference['paperId'], + 'title' => $reference['title'], + 'year' => $reference['year'] ?? 0, + 'venue' => $reference['venue'] ?? '', + 'citationCount' => $reference['citationCount'] ?? 0, + 'influentialCitationCount' => $reference['influentialCitationCount'] ?? 0, + ], $data['data'] ?? []), + 'next' => $data['next'] ?? 0, + ]; + } catch (\Exception $e) { + return [ + 'paperId' => $paperId, + 'references' => [], + 'next' => 0, + ]; + } + } + + /** + * Get paper recommendations from Semantic Scholar. + * + * @param string $paperId Paper ID + * @param int $limit Number of recommendations + * + * @return array{ + * paperId: string, + * recommendations: array, + * } + */ + public function getRecommendations( + string $paperId, + int $limit = 10, + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), + 'fields' => 'paperId,title,year,venue,citationCount', + ]; + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['x-api-key'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/recommendations/paper/{$paperId}", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'paperId' => $paperId, + 'recommendations' => array_map(fn ($recommendation) => [ + 'paperId' => $recommendation['paperId'], + 'title' => $recommendation['title'], + 'year' => $recommendation['year'] ?? 0, + 'venue' => $recommendation['venue'] ?? '', + 'citationCount' => $recommendation['citationCount'] ?? 0, + 'score' => $recommendation['score'] ?? 0.0, + ], $data['data'] ?? []), + ]; + } catch (\Exception $e) { + return [ + 'paperId' => $paperId, + 'recommendations' => [], + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/SendGrid.php b/src/agent/src/Toolbox/Tool/SendGrid.php new file mode 100644 index 000000000..37b7c051a --- /dev/null +++ b/src/agent/src/Toolbox/Tool/SendGrid.php @@ -0,0 +1,447 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('sendgrid_send_email', 'Tool that sends emails via SendGrid')] +#[AsTool('sendgrid_send_template_email', 'Tool that sends template emails via SendGrid', method: 'sendTemplateEmail')] +#[AsTool('sendgrid_get_email_activity', 'Tool that gets SendGrid email activity', method: 'getEmailActivity')] +#[AsTool('sendgrid_create_contact', 'Tool that creates SendGrid contacts', method: 'createContact')] +#[AsTool('sendgrid_get_contacts', 'Tool that gets SendGrid contacts', method: 'getContacts')] +#[AsTool('sendgrid_get_email_stats', 'Tool that gets SendGrid email statistics', method: 'getEmailStats')] +final readonly class SendGrid +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private array $options = [], + ) { + } + + /** + * Send email via SendGrid. + * + * @param string $to Recipient email address + * @param string $from Sender email address + * @param string $fromName Sender name + * @param string $subject Email subject + * @param string $htmlContent HTML email content + * @param string $textContent Plain text email content + * @param array $cc CC recipients + * @param array $bcc BCC recipients + * @param array $attachments Email attachments + */ + public function __invoke( + string $to, + string $from, + string $fromName, + string $subject, + string $htmlContent = '', + string $textContent = '', + array $cc = [], + array $bcc = [], + array $attachments = [], + ): string { + try { + $payload = [ + 'personalizations' => [ + [ + 'to' => [['email' => $to]], + 'subject' => $subject, + ], + ], + 'from' => [ + 'email' => $from, + 'name' => $fromName, + ], + 'content' => [], + ]; + + // Add content + if ($htmlContent) { + $payload['content'][] = [ + 'type' => 'text/html', + 'value' => $htmlContent, + ]; + } + if ($textContent) { + $payload['content'][] = [ + 'type' => 'text/plain', + 'value' => $textContent, + ]; + } + + // Add CC recipients + if (!empty($cc)) { + $payload['personalizations'][0]['cc'] = array_map(fn ($email) => ['email' => $email], $cc); + } + + // Add BCC recipients + if (!empty($bcc)) { + $payload['personalizations'][0]['bcc'] = array_map(fn ($email) => ['email' => $email], $bcc); + } + + // Add attachments + if (!empty($attachments)) { + $payload['attachments'] = array_map(fn ($attachment) => [ + 'filename' => $attachment['filename'], + 'content' => base64_encode($attachment['content']), + 'type' => $attachment['type'], + ], $attachments); + } + + $response = $this->httpClient->request('POST', 'https://api.sendgrid.com/v3/mail/send', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + if (202 === $response->getStatusCode()) { + return 'Email sent successfully'; + } else { + $data = $response->toArray(); + + return 'Error sending email: '.($data['errors'][0]['message'] ?? 'Unknown error'); + } + } catch (\Exception $e) { + return 'Error sending email: '.$e->getMessage(); + } + } + + /** + * Send template email via SendGrid. + * + * @param string $to Recipient email address + * @param string $from Sender email address + * @param string $fromName Sender name + * @param string $templateId SendGrid template ID + * @param array $dynamicTemplateData Template data + */ + public function sendTemplateEmail( + string $to, + string $from, + string $fromName, + string $templateId, + array $dynamicTemplateData = [], + ): string { + try { + $payload = [ + 'personalizations' => [ + [ + 'to' => [['email' => $to]], + 'dynamic_template_data' => $dynamicTemplateData, + ], + ], + 'from' => [ + 'email' => $from, + 'name' => $fromName, + ], + 'template_id' => $templateId, + ]; + + $response = $this->httpClient->request('POST', 'https://api.sendgrid.com/v3/mail/send', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + if (202 === $response->getStatusCode()) { + return 'Template email sent successfully'; + } else { + $data = $response->toArray(); + + return 'Error sending template email: '.($data['errors'][0]['message'] ?? 'Unknown error'); + } + } catch (\Exception $e) { + return 'Error sending template email: '.$e->getMessage(); + } + } + + /** + * Get SendGrid email activity. + * + * @param string $query Search query + * @param int $limit Number of results + * @param string $startDate Start date (YYYY-MM-DD) + * @param string $endDate End date (YYYY-MM-DD) + * + * @return array, + * type: string, + * tls: int, + * ip: string, + * url: string, + * useragent: string, + * reason: string, + * status: string, + * smtp-id: string, + * unique_args: array, + * }> + */ + public function getEmailActivity( + string $query = '', + int $limit = 100, + string $startDate = '', + string $endDate = '', + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 1000), + ]; + + if ($query) { + $params['query'] = $query; + } + + $response = $this->httpClient->request('GET', 'https://api.sendgrid.com/v3/messages', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + ], + 'query' => array_merge($params, [ + 'start_date' => $startDate, + 'end_date' => $endDate, + ]), + ]); + + $data = $response->toArray(); + + if (!isset($data['messages'])) { + return []; + } + + $activities = []; + foreach ($data['messages'] as $message) { + $activities[] = [ + 'email' => $message['email'] ?? '', + 'timestamp' => $message['timestamp'] ?? 0, + 'event' => $message['event'] ?? '', + 'sg_event_id' => $message['sg_event_id'] ?? '', + 'sg_message_id' => $message['sg_message_id'] ?? '', + 'response' => $message['response'] ?? '', + 'attempt' => $message['attempt'] ?? '', + 'category' => $message['category'] ?? [], + 'type' => $message['type'] ?? '', + 'tls' => $message['tls'] ?? 0, + 'ip' => $message['ip'] ?? '', + 'url' => $message['url'] ?? '', + 'useragent' => $message['useragent'] ?? '', + 'reason' => $message['reason'] ?? '', + 'status' => $message['status'] ?? '', + 'smtp-id' => $message['smtp-id'] ?? '', + 'unique_args' => $message['unique_args'] ?? [], + ]; + } + + return $activities; + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a SendGrid contact. + * + * @param string $email Contact email address + * @param string $firstName Contact first name + * @param string $lastName Contact last name + * @param array $listIds List IDs to add contact to + * @param array $customFields Custom field data + */ + public function createContact( + string $email, + string $firstName = '', + string $lastName = '', + array $listIds = [], + array $customFields = [], + ): string { + try { + $payload = [ + 'list_ids' => $listIds, + 'contacts' => [ + [ + 'email' => $email, + 'first_name' => $firstName, + 'last_name' => $lastName, + 'custom_fields' => $customFields, + ], + ], + ]; + + $response = $this->httpClient->request('PUT', 'https://api.sendgrid.com/v3/marketing/contacts', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + if (202 === $response->getStatusCode()) { + return 'Contact created successfully'; + } else { + $data = $response->toArray(); + + return 'Error creating contact: '.($data['errors'][0]['message'] ?? 'Unknown error'); + } + } catch (\Exception $e) { + return 'Error creating contact: '.$e->getMessage(); + } + } + + /** + * Get SendGrid contacts. + * + * @param int $pageSize Number of contacts per page + * @param string $query Search query + * + * @return array, + * }> + */ + public function getContacts( + int $pageSize = 100, + string $query = '', + ): array { + try { + $params = [ + 'page_size' => min(max($pageSize, 1), 1000), + ]; + + if ($query) { + $params['query'] = $query; + } + + $response = $this->httpClient->request('GET', 'https://api.sendgrid.com/v3/marketing/contacts', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + ], + 'query' => $params, + ]); + + $data = $response->toArray(); + + if (!isset($data['result'])) { + return []; + } + + $contacts = []; + foreach ($data['result'] as $contact) { + $contacts[] = [ + 'id' => $contact['id'], + 'email' => $contact['email'], + 'first_name' => $contact['first_name'] ?? '', + 'last_name' => $contact['last_name'] ?? '', + 'created_at' => $contact['created_at'], + 'updated_at' => $contact['updated_at'], + 'custom_fields' => $contact['custom_fields'] ?? [], + ]; + } + + return $contacts; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get SendGrid email statistics. + * + * @param string $startDate Start date (YYYY-MM-DD) + * @param string $endDate End date (YYYY-MM-DD) + * @param string $aggregatedBy Aggregation type (day, week, month) + * + * @return array + */ + public function getEmailStats( + string $startDate, + string $endDate, + string $aggregatedBy = 'day', + ): array { + try { + $params = [ + 'start_date' => $startDate, + 'end_date' => $endDate, + 'aggregated_by' => $aggregatedBy, + ]; + + $response = $this->httpClient->request('GET', 'https://api.sendgrid.com/v3/stats', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + ], + 'query' => $params, + ]); + + $data = $response->toArray(); + + $stats = []; + foreach ($data as $stat) { + $stats[] = [ + 'date' => $stat['date'], + 'stats' => [ + 'type' => $stat['stats'][0]['type'] ?? '', + 'name' => $stat['stats'][0]['name'] ?? '', + 'delivered' => $stat['stats'][0]['metrics']['delivered'] ?? 0, + 'requests' => $stat['stats'][0]['metrics']['requests'] ?? 0, + 'unique_clicks' => $stat['stats'][0]['metrics']['unique_clicks'] ?? 0, + 'unique_opens' => $stat['stats'][0]['metrics']['unique_opens'] ?? 0, + 'unique_unsubscribes' => $stat['stats'][0]['metrics']['unique_unsubscribes'] ?? 0, + 'bounces' => $stat['stats'][0]['metrics']['bounces'] ?? 0, + 'blocks' => $stat['stats'][0]['metrics']['blocks'] ?? 0, + 'spam_reports' => $stat['stats'][0]['metrics']['spam_reports'] ?? 0, + ], + ]; + } + + return $stats; + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Sentry.php b/src/agent/src/Toolbox/Tool/Sentry.php new file mode 100644 index 000000000..5b9556cbe --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Sentry.php @@ -0,0 +1,955 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('sentry_get_projects', 'Tool that gets Sentry projects')] +#[AsTool('sentry_get_issues', 'Tool that gets Sentry issues', method: 'getIssues')] +#[AsTool('sentry_get_events', 'Tool that gets Sentry events', method: 'getEvents')] +#[AsTool('sentry_get_releases', 'Tool that gets Sentry releases', method: 'getReleases')] +#[AsTool('sentry_get_teams', 'Tool that gets Sentry teams', method: 'getTeams')] +#[AsTool('sentry_get_organizations', 'Tool that gets Sentry organizations', method: 'getOrganizations')] +final readonly class Sentry +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiToken, + private string $organizationSlug, + private string $apiVersion = '0', + private array $options = [], + ) { + } + + /** + * Get Sentry projects. + * + * @param string $name Project name filter + * @param int $perPage Number of projects per page + * @param int $page Page number + * + * @return array, + * firstEvent: string|null, + * hasAccess: bool, + * isBookmarked: bool, + * isInternal: bool, + * isMember: bool, + * isPublic: bool, + * team: array{ + * id: string, + * name: string, + * slug: string, + * }, + * teams: array, + * organization: array{ + * id: string, + * name: string, + * slug: string, + * }, + * status: string, + * stats: array, + * }> + */ + public function __invoke( + string $name = '', + int $perPage = 50, + int $page = 1, + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + ]; + + if ($name) { + $params['query'] = $name; + } + + $response = $this->httpClient->request('GET', "https://sentry.io/api/{$this->apiVersion}/organizations/{$this->organizationSlug}/projects/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiToken, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['detail'])) { + return []; + } + + return array_map(fn ($project) => [ + 'id' => $project['id'], + 'name' => $project['name'], + 'slug' => $project['slug'], + 'platform' => $project['platform'], + 'dateCreated' => $project['dateCreated'], + 'features' => $project['features'] ?? [], + 'firstEvent' => $project['firstEvent'], + 'hasAccess' => $project['hasAccess'] ?? true, + 'isBookmarked' => $project['isBookmarked'] ?? false, + 'isInternal' => $project['isInternal'] ?? false, + 'isMember' => $project['isMember'] ?? true, + 'isPublic' => $project['isPublic'] ?? false, + 'team' => [ + 'id' => $project['team']['id'], + 'name' => $project['team']['name'], + 'slug' => $project['team']['slug'], + ], + 'teams' => array_map(fn ($team) => [ + 'id' => $team['id'], + 'name' => $team['name'], + 'slug' => $team['slug'], + ], $project['teams'] ?? []), + 'organization' => [ + 'id' => $project['organization']['id'], + 'name' => $project['organization']['name'], + 'slug' => $project['organization']['slug'], + ], + 'status' => $project['status'] ?? 'active', + 'stats' => $project['stats'] ?? [], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Sentry issues. + * + * @param string $projectSlug Project slug + * @param string $status Issue status (unresolved, resolved, ignored) + * @param string $assigned Assigned user filter + * @param string $bookmarked Bookmarked filter (true, false) + * @param string $subscribed Subscribed filter (true, false) + * @param int $perPage Number of issues per page + * @param int $page Page number + * @param string $query Search query + * + * @return array, + * isPublic: bool, + * platform: string, + * project: array{ + * id: string, + * name: string, + * slug: string, + * platform: string, + * }, + * type: string, + * metadata: array{ + * type: string, + * value: string, + * filename: string|null, + * function: string|null, + * }, + * numComments: int, + * assignedTo: array{ + * id: string, + * name: string, + * username: string, + * email: string, + * avatarUrl: string, + * isActive: bool, + * hasPasswordAuth: bool, + * isManaged: bool, + * dateJoined: string, + * lastLogin: string|null, + * isStaff: bool, + * isSuperuser: bool, + * isAuthenticated: bool, + * isAnonymous: bool, + * isActiveStaff: bool, + * isSuperuser: bool, + * flags: array, + * identities: array, + * emails: array, + * avatar: array{ + * avatarType: string, + * avatarUuid: string|null, + * avatarUrl: string, + * }, + * has2fa: bool, + * lastActive: string, + * isSuperuser: bool, + * isStaff: bool, + * experiments: array, + * permissions: array, + * }|null, + * isBookmarked: bool, + * isSubscribed: bool, + * isUnhandled: bool, + * count: string, + * userCount: int, + * firstSeen: string, + * lastSeen: string, + * stats: array{ + * '24h': array, + * '14d': array, + * }, + * issueType: string, + * issueCategory: string, + * priority: string, + * priorityLockedAt: string|null, + * hasSeen: bool, + * userReportCount: int, + * }> + */ + public function getIssues( + string $projectSlug, + string $status = 'unresolved', + string $assigned = '', + string $bookmarked = '', + string $subscribed = '', + int $perPage = 50, + int $page = 1, + string $query = '', + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + 'status' => $status, + ]; + + if ($assigned) { + $params['assigned'] = $assigned; + } + if ($bookmarked) { + $params['bookmarked'] = $bookmarked; + } + if ($subscribed) { + $params['subscribed'] = $subscribed; + } + if ($query) { + $params['query'] = $query; + } + + $response = $this->httpClient->request('GET', "https://sentry.io/api/{$this->apiVersion}/projects/{$this->organizationSlug}/{$projectSlug}/issues/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiToken, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['detail'])) { + return []; + } + + return array_map(fn ($issue) => [ + 'id' => $issue['id'], + 'shareId' => $issue['shareId'], + 'shortId' => $issue['shortId'], + 'title' => $issue['title'], + 'culprit' => $issue['culprit'], + 'permalink' => $issue['permalink'], + 'logger' => $issue['logger'], + 'level' => $issue['level'], + 'status' => $issue['status'], + 'statusDetails' => $issue['statusDetails'] ?? [], + 'isPublic' => $issue['isPublic'] ?? false, + 'platform' => $issue['platform'], + 'project' => [ + 'id' => $issue['project']['id'], + 'name' => $issue['project']['name'], + 'slug' => $issue['project']['slug'], + 'platform' => $issue['project']['platform'], + ], + 'type' => $issue['type'], + 'metadata' => [ + 'type' => $issue['metadata']['type'], + 'value' => $issue['metadata']['value'], + 'filename' => $issue['metadata']['filename'], + 'function' => $issue['metadata']['function'], + ], + 'numComments' => $issue['numComments'] ?? 0, + 'assignedTo' => $issue['assignedTo'] ? [ + 'id' => $issue['assignedTo']['id'], + 'name' => $issue['assignedTo']['name'], + 'username' => $issue['assignedTo']['username'], + 'email' => $issue['assignedTo']['email'], + 'avatarUrl' => $issue['assignedTo']['avatarUrl'], + 'isActive' => $issue['assignedTo']['isActive'], + 'hasPasswordAuth' => $issue['assignedTo']['hasPasswordAuth'] ?? false, + 'isManaged' => $issue['assignedTo']['isManaged'] ?? false, + 'dateJoined' => $issue['assignedTo']['dateJoined'], + 'lastLogin' => $issue['assignedTo']['lastLogin'], + 'isStaff' => $issue['assignedTo']['isStaff'] ?? false, + 'isSuperuser' => $issue['assignedTo']['isSuperuser'] ?? false, + 'isAuthenticated' => $issue['assignedTo']['isAuthenticated'] ?? true, + 'isAnonymous' => $issue['assignedTo']['isAnonymous'] ?? false, + 'isActiveStaff' => $issue['assignedTo']['isActiveStaff'] ?? false, + 'flags' => $issue['assignedTo']['flags'] ?? [], + 'identities' => $issue['assignedTo']['identities'] ?? [], + 'emails' => $issue['assignedTo']['emails'] ?? [], + 'avatar' => [ + 'avatarType' => $issue['assignedTo']['avatar']['avatarType'], + 'avatarUuid' => $issue['assignedTo']['avatar']['avatarUuid'], + 'avatarUrl' => $issue['assignedTo']['avatar']['avatarUrl'], + ], + 'has2fa' => $issue['assignedTo']['has2fa'] ?? false, + 'lastActive' => $issue['assignedTo']['lastActive'], + 'experiments' => $issue['assignedTo']['experiments'] ?? [], + 'permissions' => $issue['assignedTo']['permissions'] ?? [], + ] : null, + 'isBookmarked' => $issue['isBookmarked'] ?? false, + 'isSubscribed' => $issue['isSubscribed'] ?? false, + 'isUnhandled' => $issue['isUnhandled'] ?? false, + 'count' => $issue['count'], + 'userCount' => $issue['userCount'], + 'firstSeen' => $issue['firstSeen'], + 'lastSeen' => $issue['lastSeen'], + 'stats' => [ + '24h' => $issue['stats']['24h'] ?? [], + '14d' => $issue['stats']['14d'] ?? [], + ], + 'issueType' => $issue['issueType'] ?? 'error', + 'issueCategory' => $issue['issueCategory'] ?? 'error', + 'priority' => $issue['priority'] ?? 'medium', + 'priorityLockedAt' => $issue['priorityLockedAt'], + 'hasSeen' => $issue['hasSeen'] ?? false, + 'userReportCount' => $issue['userReportCount'] ?? 0, + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Sentry events. + * + * @param string $projectSlug Project slug + * @param string $issueId Issue ID + * @param int $perPage Number of events per page + * @param int $page Page number + * + * @return array, + * user: array{ + * id: string|null, + * username: string|null, + * email: string|null, + * ip_address: string|null, + * data: array, + * }|null, + * contexts: array, + * sdk: array{ + * name: string, + * version: string, + * packages: array, + * integrations: array, + * }, + * groupID: string, + * fingerprints: array, + * metadata: array{ + * type: string, + * value: string, + * filename: string|null, + * function: string|null, + * }, + * size: int, + * entries: array, + * }>, + * packages: array, + * release: array{ + * version: string, + * shortVersion: string, + * versionInfo: array{ + * version: array{ + * raw: string, + * major: int, + * minor: int, + * patch: int, + * pre: string|null, + * buildCode: string|null, + * buildNumber: int|null, + * }, + * }, + * projects: array, + * }>, + * dateCreated: string, + * dateReleased: string|null, + * dateStarted: string|null, + * dateFinished: string|null, + * data: array, + * lastEvent: string|null, + * firstEvent: string|null, + * lastCommit: array{ + * id: string, + * repository: array{ + * id: string, + * name: string, + * url: string, + * provider: array{ + * id: string, + * name: string, + * }, + * }, + * shortId: string, + * title: string, + * authorName: string, + * authorEmail: string, + * message: string, + * dateCreated: string, + * }|null, + * newGroups: int, + * owner: array{ + * id: string, + * name: string, + * type: string, + * }|null, + * ref: string|null, + * url: string|null, + * version: string, + * shortVersion: string, + * versionInfo: array{ + * version: array{ + * raw: string, + * major: int, + * minor: int, + * patch: int, + * pre: string|null, + * buildCode: string|null, + * buildNumber: int|null, + * }, + * }, + * projects: array, + * }>, + * dateCreated: string, + * dateReleased: string|null, + * dateStarted: string|null, + * dateFinished: string|null, + * data: array, + * lastEvent: string|null, + * firstEvent: string|null, + * lastCommit: array{ + * id: string, + * repository: array{ + * id: string, + * name: string, + * url: string, + * provider: array{ + * id: string, + * name: string, + * }, + * }, + * shortId: string, + * title: string, + * authorName: string, + * authorEmail: string, + * message: string, + * dateCreated: string, + * }|null, + * newGroups: int, + * owner: array{ + * id: string, + * name: string, + * type: string, + * }|null, + * ref: string|null, + * url: string|null, + * }|null, + * }> + */ + public function getEvents( + string $projectSlug, + string $issueId, + int $perPage = 50, + int $page = 1, + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + ]; + + $response = $this->httpClient->request('GET', "https://sentry.io/api/{$this->apiVersion}/projects/{$this->organizationSlug}/{$projectSlug}/issues/{$issueId}/events/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiToken, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['detail'])) { + return []; + } + + return array_map(fn ($event) => [ + 'id' => $event['id'], + 'eventID' => $event['eventID'], + 'dist' => $event['dist'], + 'message' => $event['message'], + 'title' => $event['title'], + 'culprit' => $event['culprit'], + 'dateCreated' => $event['dateCreated'], + 'dateReceived' => $event['dateReceived'], + 'platform' => $event['platform'], + 'type' => $event['type'], + 'tags' => array_map(fn ($tag) => [ + 'key' => $tag['key'], + 'value' => $tag['value'], + ], $event['tags'] ?? []), + 'user' => $event['user'] ? [ + 'id' => $event['user']['id'], + 'username' => $event['user']['username'], + 'email' => $event['user']['email'], + 'ip_address' => $event['user']['ip_address'], + 'data' => $event['user']['data'] ?? [], + ] : null, + 'contexts' => $event['contexts'] ?? [], + 'sdk' => [ + 'name' => $event['sdk']['name'], + 'version' => $event['sdk']['version'], + 'packages' => array_map(fn ($package) => [ + 'name' => $package['name'], + 'version' => $package['version'], + ], $event['sdk']['packages'] ?? []), + 'integrations' => $event['sdk']['integrations'] ?? [], + ], + 'groupID' => $event['groupID'], + 'fingerprints' => $event['fingerprints'] ?? [], + 'metadata' => [ + 'type' => $event['metadata']['type'], + 'value' => $event['metadata']['value'], + 'filename' => $event['metadata']['filename'], + 'function' => $event['metadata']['function'], + ], + 'size' => $event['size'], + 'entries' => array_map(fn ($entry) => [ + 'type' => $entry['type'], + 'data' => $entry['data'], + ], $event['entries'] ?? []), + 'packages' => $event['packages'] ?? [], + 'release' => $event['release'], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Sentry releases. + * + * @param string $projectSlug Project slug + * @param int $perPage Number of releases per page + * @param int $page Page number + * @param string $query Search query + * + * @return array, + * }>, + * dateCreated: string, + * dateReleased: string|null, + * dateStarted: string|null, + * dateFinished: string|null, + * data: array, + * lastEvent: string|null, + * firstEvent: string|null, + * lastCommit: array{ + * id: string, + * repository: array{ + * id: string, + * name: string, + * url: string, + * provider: array{ + * id: string, + * name: string, + * }, + * }, + * shortId: string, + * title: string, + * authorName: string, + * authorEmail: string, + * message: string, + * dateCreated: string, + * }|null, + * newGroups: int, + * owner: array{ + * id: string, + * name: string, + * type: string, + * }|null, + * ref: string|null, + * url: string|null, + * }> + */ + public function getReleases( + string $projectSlug, + int $perPage = 50, + int $page = 1, + string $query = '', + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + ]; + + if ($query) { + $params['query'] = $query; + } + + $response = $this->httpClient->request('GET', "https://sentry.io/api/{$this->apiVersion}/projects/{$this->organizationSlug}/{$projectSlug}/releases/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiToken, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['detail'])) { + return []; + } + + return array_map(fn ($release) => [ + 'version' => $release['version'], + 'shortVersion' => $release['shortVersion'], + 'versionInfo' => [ + 'version' => [ + 'raw' => $release['versionInfo']['version']['raw'], + 'major' => $release['versionInfo']['version']['major'], + 'minor' => $release['versionInfo']['version']['minor'], + 'patch' => $release['versionInfo']['version']['patch'], + 'pre' => $release['versionInfo']['version']['pre'], + 'buildCode' => $release['versionInfo']['version']['buildCode'], + 'buildNumber' => $release['versionInfo']['version']['buildNumber'], + ], + ], + 'projects' => array_map(fn ($project) => [ + 'id' => $project['id'], + 'name' => $project['name'], + 'slug' => $project['slug'], + 'newGroups' => $project['newGroups'], + 'platform' => $project['platform'], + 'platforms' => $project['platforms'] ?? [], + ], $release['projects'] ?? []), + 'dateCreated' => $release['dateCreated'], + 'dateReleased' => $release['dateReleased'], + 'dateStarted' => $release['dateStarted'], + 'dateFinished' => $release['dateFinished'], + 'data' => $release['data'] ?? [], + 'lastEvent' => $release['lastEvent'], + 'firstEvent' => $release['firstEvent'], + 'lastCommit' => $release['lastCommit'], + 'newGroups' => $release['newGroups'] ?? 0, + 'owner' => $release['owner'], + 'ref' => $release['ref'], + 'url' => $release['url'], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Sentry teams. + * + * @param int $perPage Number of teams per page + * @param int $page Page number + * @param string $query Search query + * + * @return array, + * dateCreated: string, + * organization: array{ + * id: string, + * name: string, + * slug: string, + * }, + * }> + */ + public function getTeams( + int $perPage = 50, + int $page = 1, + string $query = '', + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + ]; + + if ($query) { + $params['query'] = $query; + } + + $response = $this->httpClient->request('GET', "https://sentry.io/api/{$this->apiVersion}/organizations/{$this->organizationSlug}/teams/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiToken, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['detail'])) { + return []; + } + + return array_map(fn ($team) => [ + 'id' => $team['id'], + 'name' => $team['name'], + 'slug' => $team['slug'], + 'isMember' => $team['isMember'] ?? false, + 'isPending' => $team['isPending'] ?? false, + 'memberCount' => $team['memberCount'] ?? 0, + 'hasAccess' => $team['hasAccess'] ?? true, + 'isAccessGranted' => $team['isAccessGranted'] ?? true, + 'isMemberIdpProvisioned' => $team['isMemberIdpProvisioned'] ?? false, + 'flags' => $team['flags'] ?? [], + 'dateCreated' => $team['dateCreated'], + 'organization' => [ + 'id' => $team['organization']['id'], + 'name' => $team['organization']['name'], + 'slug' => $team['organization']['slug'], + ], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Sentry organizations. + * + * @param int $perPage Number of organizations per page + * @param int $page Page number + * @param string $query Search query + * + * @return array, + * links: array, + * isDefault: bool, + * isUnclaimed: bool, + * onboardingTasks: array, + * experiments: array, + * alertSettings: array, + * scrubIPAddresses: bool, + * scrubData: bool, + * sensitiveFields: array, + * safeFields: array, + * storeCrashReports: bool, + * attachments: array{ + * minidump: bool, + * }, + * dataScrubber: bool, + * dataScrubberDefaults: bool, + * debugFiles: array{ + * bundleId: string, + * checksums: array, + * dateCreated: string, + * debugId: string, + * objectName: string, + * sha1: string, + * symbolType: string, + * uuid: string, + * }|null, + * scrapeJavaScript: bool, + * allowJoinRequests: bool, + * enhancedPrivacy: bool, + * isDynamicallySampled: bool, + * openMembership: bool, + * pendingAccessRequests: int, + * quota: array{ + * maxRate: int, + * maxRateInterval: int, + * accountLimit: int, + * projectLimit: int, + * }, + * trustedRelays: array, + * orgRole: string, + * role: string, + * projects: array, + * teams: array, + * }> + */ + public function getOrganizations( + int $perPage = 50, + int $page = 1, + string $query = '', + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + ]; + + if ($query) { + $params['query'] = $query; + } + + $response = $this->httpClient->request('GET', "https://sentry.io/api/{$this->apiVersion}/organizations/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiToken, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['detail'])) { + return []; + } + + return array_map(fn ($org) => [ + 'id' => $org['id'], + 'name' => $org['name'], + 'slug' => $org['slug'], + 'dateCreated' => $org['dateCreated'], + 'isEarlyAdopter' => $org['isEarlyAdopter'] ?? false, + 'require2FA' => $org['require2FA'] ?? false, + 'avatar' => [ + 'avatarType' => $org['avatar']['avatarType'], + 'avatarUuid' => $org['avatar']['avatarUuid'], + 'avatarUrl' => $org['avatar']['avatarUrl'], + ], + 'features' => $org['features'] ?? [], + 'links' => $org['links'] ?? [], + 'isDefault' => $org['isDefault'] ?? false, + 'isUnclaimed' => $org['isUnclaimed'] ?? false, + 'onboardingTasks' => $org['onboardingTasks'] ?? [], + 'experiments' => $org['experiments'] ?? [], + 'alertSettings' => $org['alertSettings'] ?? [], + 'scrubIPAddresses' => $org['scrubIPAddresses'] ?? false, + 'scrubData' => $org['scrubData'] ?? false, + 'sensitiveFields' => $org['sensitiveFields'] ?? [], + 'safeFields' => $org['safeFields'] ?? [], + 'storeCrashReports' => $org['storeCrashReports'] ?? false, + 'attachments' => [ + 'minidump' => $org['attachments']['minidump'] ?? false, + ], + 'dataScrubber' => $org['dataScrubber'] ?? false, + 'dataScrubberDefaults' => $org['dataScrubberDefaults'] ?? false, + 'debugFiles' => $org['debugFiles'], + 'scrapeJavaScript' => $org['scrapeJavaScript'] ?? false, + 'allowJoinRequests' => $org['allowJoinRequests'] ?? false, + 'enhancedPrivacy' => $org['enhancedPrivacy'] ?? false, + 'isDynamicallySampled' => $org['isDynamicallySampled'] ?? false, + 'openMembership' => $org['openMembership'] ?? false, + 'pendingAccessRequests' => $org['pendingAccessRequests'] ?? 0, + 'quota' => [ + 'maxRate' => $org['quota']['maxRate'], + 'maxRateInterval' => $org['quota']['maxRateInterval'], + 'accountLimit' => $org['quota']['accountLimit'], + 'projectLimit' => $org['quota']['projectLimit'], + ], + 'trustedRelays' => $org['trustedRelays'] ?? [], + 'orgRole' => $org['orgRole'] ?? 'member', + 'role' => $org['role'] ?? 'member', + 'projects' => $org['projects'] ?? [], + 'teams' => $org['teams'] ?? [], + ], $data); + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Shell.php b/src/agent/src/Toolbox/Tool/Shell.php new file mode 100644 index 000000000..246c6c5c4 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Shell.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; + +/** + * @author Mathieu Ledru + */ +#[AsTool('terminal', 'Tool to run shell commands on this machine')] +final readonly class Shell +{ + public function __construct( + private bool $askHumanInput = false, + private int $timeout = 30, + ) { + } + + /** + * @param string|array $commands List of shell commands to run + * @param bool $returnErrOutput Whether to return error output + */ + public function __invoke( + string|array $commands, + bool $returnErrOutput = true, + ): string { + // Normalize commands to array + if (\is_string($commands)) { + $commands = [$commands]; + } + + if (empty($commands)) { + return 'Error: No commands provided'; + } + + // Ask for human confirmation if enabled + if ($this->askHumanInput) { + $commandString = implode('; ', $commands); + echo "Executing command:\n {$commandString}\n"; + $userInput = readline('Proceed with command execution? (y/n): '); + + if ('y' !== strtolower($userInput)) { + return 'Command execution aborted by user.'; + } + } + + try { + $results = []; + + foreach ($commands as $command) { + $result = $this->executeCommand($command, $returnErrOutput); + $results[] = $result; + } + + return implode("\n---\n", $results); + } catch (\Exception $e) { + return 'Error during command execution: '.$e->getMessage(); + } + } + + /** + * Get platform information. + */ + public function getPlatform(): string + { + return match (\PHP_OS_FAMILY) { + 'Darwin' => 'macOS', + 'Windows' => 'Windows', + 'Linux' => 'Linux', + default => \PHP_OS_FAMILY, + }; + } + + private function executeCommand(string $command, bool $returnErrOutput): string + { + try { + // Determine the appropriate shell based on the operating system + $shell = $this->getShellCommand(); + + // Use exec with output capture + $output = []; + $returnCode = 0; + + if ('cmd' === $shell) { + // Windows command execution + $fullCommand = "cmd /c \"{$command}\""; + } else { + // Unix-like command execution + $fullCommand = "{$shell} -c ".escapeshellarg($command); + } + + exec($fullCommand.' 2>&1', $output, $returnCode); + $outputString = implode("\n", $output); + + $result = ''; + if (!empty($outputString)) { + $result .= "STDOUT:\n".$outputString; + } + + if (0 !== $returnCode) { + $result .= "\nExit code: ".$returnCode; + } + + return $result ?: 'Command executed successfully (no output)'; + } catch (\Exception $e) { + return 'Error executing command: '.$e->getMessage(); + } + } + + private function getShellCommand(): string + { + $os = \PHP_OS_FAMILY; + + return match ($os) { + 'Windows' => 'cmd', + 'Darwin', 'Linux' => '/bin/bash', + default => '/bin/sh', + }; + } +} diff --git a/src/agent/src/Toolbox/Tool/Shopify.php b/src/agent/src/Toolbox/Tool/Shopify.php new file mode 100644 index 000000000..7fc3fabc1 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Shopify.php @@ -0,0 +1,949 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('shopify_get_products', 'Tool that gets Shopify products')] +#[AsTool('shopify_create_product', 'Tool that creates Shopify products', method: 'createProduct')] +#[AsTool('shopify_get_orders', 'Tool that gets Shopify orders', method: 'getOrders')] +#[AsTool('shopify_create_order', 'Tool that creates Shopify orders', method: 'createOrder')] +#[AsTool('shopify_get_customers', 'Tool that gets Shopify customers', method: 'getCustomers')] +#[AsTool('shopify_create_customer', 'Tool that creates Shopify customers', method: 'createCustomer')] +#[AsTool('shopify_update_inventory', 'Tool that updates Shopify inventory', method: 'updateInventory')] +final readonly class Shopify +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $shopDomain, + #[\SensitiveParameter] private string $accessToken, + private array $options = [], + ) { + } + + /** + * Get Shopify products. + * + * @param int $limit Number of products to retrieve (1-250) + * @param string $ids Comma-separated list of product IDs + * @param string $title Filter by product title + * @param string $vendor Filter by vendor + * @param string $productType Filter by product type + * @param string $status Filter by status (active, archived, draft) + * + * @return array, + * options: array, + * }>, + * images: array, + * admin_graphql_api_id: string, + * }>, + * image: array{ + * id: int, + * product_id: int, + * position: int, + * created_at: string, + * updated_at: string, + * alt: string, + * width: int, + * height: int, + * src: string, + * variant_ids: array, + * admin_graphql_api_id: string, + * }|null, + * }> + */ + public function __invoke( + int $limit = 50, + string $ids = '', + string $title = '', + string $vendor = '', + string $productType = '', + string $status = 'active', + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 250), + ]; + + if ($ids) { + $params['ids'] = $ids; + } + if ($title) { + $params['title'] = $title; + } + if ($vendor) { + $params['vendor'] = $vendor; + } + if ($productType) { + $params['product_type'] = $productType; + } + if ($status) { + $params['status'] = $status; + } + + $response = $this->httpClient->request('GET', "https://{$this->shopDomain}.myshopify.com/admin/api/2023-10/products.json", [ + 'headers' => [ + 'X-Shopify-Access-Token' => $this->accessToken, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['products'])) { + return []; + } + + $products = []; + foreach ($data['products'] as $product) { + $products[] = [ + 'id' => $product['id'], + 'title' => $product['title'], + 'body_html' => $product['body_html'] ?? '', + 'vendor' => $product['vendor'], + 'product_type' => $product['product_type'], + 'created_at' => $product['created_at'], + 'updated_at' => $product['updated_at'], + 'published_at' => $product['published_at'] ?? '', + 'template_suffix' => $product['template_suffix'] ?? '', + 'status' => $product['status'], + 'published_scope' => $product['published_scope'], + 'tags' => $product['tags'] ?? '', + 'admin_graphql_api_id' => $product['admin_graphql_api_id'], + 'variants' => array_map(fn ($variant) => [ + 'id' => $variant['id'], + 'product_id' => $variant['product_id'], + 'title' => $variant['title'], + 'price' => $variant['price'], + 'sku' => $variant['sku'] ?? '', + 'position' => $variant['position'], + 'inventory_policy' => $variant['inventory_policy'], + 'compare_at_price' => $variant['compare_at_price'] ?? '', + 'fulfillment_service' => $variant['fulfillment_service'], + 'inventory_management' => $variant['inventory_management'], + 'option1' => $variant['option1'] ?? '', + 'option2' => $variant['option2'] ?? '', + 'option3' => $variant['option3'] ?? '', + 'created_at' => $variant['created_at'], + 'updated_at' => $variant['updated_at'], + 'taxable' => $variant['taxable'], + 'barcode' => $variant['barcode'] ?? '', + 'grams' => $variant['grams'], + 'image_id' => $variant['image_id'] ?? null, + 'weight' => $variant['weight'], + 'weight_unit' => $variant['weight_unit'], + 'inventory_item_id' => $variant['inventory_item_id'], + 'inventory_quantity' => $variant['inventory_quantity'] ?? 0, + 'old_inventory_quantity' => $variant['old_inventory_quantity'] ?? 0, + 'requires_shipping' => $variant['requires_shipping'], + 'admin_graphql_api_id' => $variant['admin_graphql_api_id'], + ], $product['variants']), + 'options' => array_map(fn ($option) => [ + 'id' => $option['id'], + 'product_id' => $option['product_id'], + 'name' => $option['name'], + 'position' => $option['position'], + 'values' => $option['values'], + ], $product['options']), + 'images' => array_map(fn ($image) => [ + 'id' => $image['id'], + 'product_id' => $image['product_id'], + 'position' => $image['position'], + 'created_at' => $image['created_at'], + 'updated_at' => $image['updated_at'], + 'alt' => $image['alt'] ?? '', + 'width' => $image['width'], + 'height' => $image['height'], + 'src' => $image['src'], + 'variant_ids' => $image['variant_ids'], + 'admin_graphql_api_id' => $image['admin_graphql_api_id'], + ], $product['images']), + 'image' => $product['image'] ? [ + 'id' => $product['image']['id'], + 'product_id' => $product['image']['product_id'], + 'position' => $product['image']['position'], + 'created_at' => $product['image']['created_at'], + 'updated_at' => $product['image']['updated_at'], + 'alt' => $product['image']['alt'] ?? '', + 'width' => $product['image']['width'], + 'height' => $product['image']['height'], + 'src' => $product['image']['src'], + 'variant_ids' => $product['image']['variant_ids'], + 'admin_graphql_api_id' => $product['image']['admin_graphql_api_id'], + ] : null, + ]; + } + + return $products; + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a Shopify product. + * + * @param string $title Product title + * @param string $bodyHtml Product description HTML + * @param string $vendor Product vendor + * @param string $productType Product type + * @param string $tags Product tags (comma-separated) + * @param array $variants Product variants + * @param array $images Product images + * + * @return array{ + * id: int, + * title: string, + * body_html: string, + * vendor: string, + * product_type: string, + * created_at: string, + * updated_at: string, + * published_at: string, + * status: string, + * published_scope: string, + * tags: string, + * admin_graphql_api_id: string, + * variants: array>, + * options: array>, + * images: array>, + * }|string + */ + public function createProduct( + string $title, + string $bodyHtml = '', + string $vendor = '', + string $productType = '', + string $tags = '', + array $variants = [], + array $images = [], + ): array|string { + try { + $payload = [ + 'product' => [ + 'title' => $title, + 'body_html' => $bodyHtml, + 'vendor' => $vendor, + 'product_type' => $productType, + 'tags' => $tags, + ], + ]; + + if (!empty($variants)) { + $payload['product']['variants'] = array_map(fn ($variant) => [ + 'title' => $variant['title'], + 'price' => $variant['price'], + 'sku' => $variant['sku'] ?? '', + 'inventory_quantity' => $variant['inventory_quantity'] ?? 0, + ], $variants); + } + + if (!empty($images)) { + $payload['product']['images'] = array_map(fn ($image) => [ + 'src' => $image['src'], + 'alt' => $image['alt'] ?? '', + ], $images); + } + + $response = $this->httpClient->request('POST', "https://{$this->shopDomain}.myshopify.com/admin/api/2023-10/products.json", [ + 'headers' => [ + 'X-Shopify-Access-Token' => $this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error creating product: '.implode(', ', $data['errors']); + } + + $product = $data['product']; + + return [ + 'id' => $product['id'], + 'title' => $product['title'], + 'body_html' => $product['body_html'] ?? '', + 'vendor' => $product['vendor'], + 'product_type' => $product['product_type'], + 'created_at' => $product['created_at'], + 'updated_at' => $product['updated_at'], + 'published_at' => $product['published_at'] ?? '', + 'status' => $product['status'], + 'published_scope' => $product['published_scope'], + 'tags' => $product['tags'] ?? '', + 'admin_graphql_api_id' => $product['admin_graphql_api_id'], + 'variants' => $product['variants'], + 'options' => $product['options'], + 'images' => $product['images'], + ]; + } catch (\Exception $e) { + return 'Error creating product: '.$e->getMessage(); + } + } + + /** + * Get Shopify orders. + * + * @param int $limit Number of orders to retrieve (1-250) + * @param string $status Filter by status (open, closed, cancelled, any) + * @param string $financialStatus Filter by financial status (authorized, pending, paid, partially_paid, refunded, voided, partially_refunded, unpaid, any) + * @param string $fulfillmentStatus Filter by fulfillment status (fulfilled, null, partial, restocked, any) + * @param string $createdAtMin Filter orders created after this date + * @param string $createdAtMax Filter orders created before this date + * + * @return array, + * taxes_included: bool, + * test: bool, + * token: string, + * total_discounts: string, + * total_discounts_set: array{shop_money: array{amount: string, currency_code: string}, presentment_money: array{amount: string, currency_code: string}}, + * total_line_items_price: string, + * total_line_items_price_set: array{shop_money: array{amount: string, currency_code: string}, presentment_money: array{amount: string, currency_code: string}}, + * total_outstanding: string, + * total_price: string, + * total_price_set: array{shop_money: array{amount: string, currency_code: string}, presentment_money: array{amount: string, currency_code: string}}, + * total_price_usd: string, + * total_shipping_price_set: array{shop_money: array{amount: string, currency_code: string}, presentment_money: array{amount: string, currency_code: string}}, + * total_tax: string, + * total_tax_set: array{shop_money: array{amount: string, currency_code: string}, presentment_money: array{amount: string, currency_code: string}}, + * total_tip_received: string, + * total_weight: int, + * updated_at: string, + * user_id: int, + * billing_address: array|null, + * customer: array{ + * id: int, + * email: string, + * accepts_marketing: bool, + * created_at: string, + * updated_at: string, + * first_name: string, + * last_name: string, + * orders_count: int, + * state: string, + * total_spent: string, + * last_order_id: int, + * note: string, + * verified_email: bool, + * multipass_identifier: string, + * tax_exempt: bool, + * phone: string, + * tags: string, + * last_order_name: string, + * currency: string, + * accepts_marketing_updated_at: string, + * marketing_opt_in_level: string, + * tax_exemptions: array, + * admin_graphql_api_id: string, + * default_address: array, + * }, + * discount_applications: array>, + * fulfillments: array>, + * line_items: array, + * quantity: int, + * requires_shipping: bool, + * sku: string, + * taxable: bool, + * title: string, + * total_discount: string, + * total_discount_set: array{shop_money: array{amount: string, currency_code: string}, presentment_money: array{amount: string, currency_code: string}}, + * variant_id: int, + * variant_inventory_management: string, + * variant_title: string, + * vendor: string, + * tax_lines: array, + * duties: array>, + * discount_allocations: array>, + * }>, + * payment_terms: array|null, + * refunds: array>, + * shipping_address: array|null, + * shipping_lines: array>, + * tax_lines: array, + * }>, + * }> + */ + public function getOrders( + int $limit = 50, + string $status = '', + string $financialStatus = '', + string $fulfillmentStatus = '', + string $createdAtMin = '', + string $createdAtMax = '', + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 250), + ]; + + if ($status) { + $params['status'] = $status; + } + if ($financialStatus) { + $params['financial_status'] = $financialStatus; + } + if ($fulfillmentStatus) { + $params['fulfillment_status'] = $fulfillmentStatus; + } + if ($createdAtMin) { + $params['created_at_min'] = $createdAtMin; + } + if ($createdAtMax) { + $params['created_at_max'] = $createdAtMax; + } + + $response = $this->httpClient->request('GET', "https://{$this->shopDomain}.myshopify.com/admin/api/2023-10/orders.json", [ + 'headers' => [ + 'X-Shopify-Access-Token' => $this->accessToken, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['orders'])) { + return []; + } + + // Return simplified order structure due to length constraints + $orders = []; + foreach ($data['orders'] as $order) { + $orders[] = [ + 'id' => $order['id'], + 'name' => $order['name'], + 'email' => $order['email'] ?? '', + 'created_at' => $order['created_at'], + 'updated_at' => $order['updated_at'], + 'total_price' => $order['total_price'], + 'subtotal_price' => $order['subtotal_price'], + 'total_tax' => $order['total_tax'], + 'currency' => $order['currency'], + 'financial_status' => $order['financial_status'], + 'fulfillment_status' => $order['fulfillment_status'] ?? '', + 'billing_address' => $order['billing_address'] ?? null, + 'shipping_address' => $order['shipping_address'] ?? null, + 'customer' => $order['customer'] ?? null, + 'line_items' => array_map(fn ($item) => [ + 'id' => $item['id'], + 'name' => $item['name'], + 'quantity' => $item['quantity'], + 'price' => $item['price'], + 'total_discount' => $item['total_discount'] ?? '0.00', + 'vendor' => $item['vendor'] ?? '', + 'product_id' => $item['product_id'] ?? null, + 'variant_id' => $item['variant_id'] ?? null, + ], $order['line_items']), + ]; + } + + return $orders; + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a Shopify order. + * + * @param array $lineItems Order line items + * @param string $email Customer email + * @param array $billingAddress Billing address + * @param array $shippingAddress Shipping address + * @param string $financialStatus Financial status + * @param string $currency Currency code + * @param string $note Order note + * + * @return array{ + * id: int, + * name: string, + * email: string, + * created_at: string, + * updated_at: string, + * total_price: string, + * subtotal_price: string, + * total_tax: string, + * currency: string, + * financial_status: string, + * fulfillment_status: string, + * line_items: array>, + * }|string + */ + public function createOrder( + array $lineItems, + string $email, + array $billingAddress = [], + array $shippingAddress = [], + string $financialStatus = 'pending', + string $currency = 'USD', + string $note = '', + ): array|string { + try { + $payload = [ + 'order' => [ + 'line_items' => array_map(fn ($item) => [ + 'title' => $item['title'], + 'price' => $item['price'], + 'quantity' => $item['quantity'], + 'variant_id' => $item['variant_id'] ?? null, + ], $lineItems), + 'email' => $email, + 'financial_status' => $financialStatus, + 'currency' => $currency, + ], + ]; + + if ($note) { + $payload['order']['note'] = $note; + } + + if (!empty($billingAddress)) { + $payload['order']['billing_address'] = $billingAddress; + } + + if (!empty($shippingAddress)) { + $payload['order']['shipping_address'] = $shippingAddress; + } + + $response = $this->httpClient->request('POST', "https://{$this->shopDomain}.myshopify.com/admin/api/2023-10/orders.json", [ + 'headers' => [ + 'X-Shopify-Access-Token' => $this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error creating order: '.implode(', ', $data['errors']); + } + + $order = $data['order']; + + return [ + 'id' => $order['id'], + 'name' => $order['name'], + 'email' => $order['email'], + 'created_at' => $order['created_at'], + 'updated_at' => $order['updated_at'], + 'total_price' => $order['total_price'], + 'subtotal_price' => $order['subtotal_price'], + 'total_tax' => $order['total_tax'], + 'currency' => $order['currency'], + 'financial_status' => $order['financial_status'], + 'fulfillment_status' => $order['fulfillment_status'] ?? '', + 'line_items' => $order['line_items'], + ]; + } catch (\Exception $e) { + return 'Error creating order: '.$e->getMessage(); + } + } + + /** + * Get Shopify customers. + * + * @param int $limit Number of customers to retrieve (1-250) + * @param string $ids Comma-separated list of customer IDs + * @param string $query Search query + * + * @return array, + * admin_graphql_api_id: string, + * default_address: array, + * }> + */ + public function getCustomers( + int $limit = 50, + string $ids = '', + string $query = '', + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 250), + ]; + + if ($ids) { + $params['ids'] = $ids; + } + if ($query) { + $params['query'] = $query; + } + + $response = $this->httpClient->request('GET', "https://{$this->shopDomain}.myshopify.com/admin/api/2023-10/customers.json", [ + 'headers' => [ + 'X-Shopify-Access-Token' => $this->accessToken, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['customers'])) { + return []; + } + + $customers = []; + foreach ($data['customers'] as $customer) { + $customers[] = [ + 'id' => $customer['id'], + 'email' => $customer['email'], + 'accepts_marketing' => $customer['accepts_marketing'], + 'created_at' => $customer['created_at'], + 'updated_at' => $customer['updated_at'], + 'first_name' => $customer['first_name'] ?? '', + 'last_name' => $customer['last_name'] ?? '', + 'orders_count' => $customer['orders_count'], + 'state' => $customer['state'], + 'total_spent' => $customer['total_spent'], + 'last_order_id' => $customer['last_order_id'] ?? null, + 'note' => $customer['note'] ?? '', + 'verified_email' => $customer['verified_email'], + 'multipass_identifier' => $customer['multipass_identifier'] ?? '', + 'tax_exempt' => $customer['tax_exempt'], + 'phone' => $customer['phone'] ?? '', + 'tags' => $customer['tags'] ?? '', + 'last_order_name' => $customer['last_order_name'] ?? '', + 'currency' => $customer['currency'], + 'accepts_marketing_updated_at' => $customer['accepts_marketing_updated_at'] ?? '', + 'marketing_opt_in_level' => $customer['marketing_opt_in_level'] ?? '', + 'tax_exemptions' => $customer['tax_exemptions'] ?? [], + 'admin_graphql_api_id' => $customer['admin_graphql_api_id'], + 'default_address' => $customer['default_address'] ?? [], + ]; + } + + return $customers; + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a Shopify customer. + * + * @param string $email Customer email + * @param string $firstName Customer first name + * @param string $lastName Customer last name + * @param string $phone Customer phone number + * @param bool $acceptsMarketing Whether customer accepts marketing + * @param string $note Customer note + * @param array $address Customer default address + * + * @return array{ + * id: int, + * email: string, + * accepts_marketing: bool, + * created_at: string, + * updated_at: string, + * first_name: string, + * last_name: string, + * orders_count: int, + * state: string, + * total_spent: string, + * note: string, + * verified_email: bool, + * phone: string, + * currency: string, + * admin_graphql_api_id: string, + * }|string + */ + public function createCustomer( + string $email, + string $firstName = '', + string $lastName = '', + string $phone = '', + bool $acceptsMarketing = false, + string $note = '', + array $address = [], + ): array|string { + try { + $payload = [ + 'customer' => [ + 'email' => $email, + 'accepts_marketing' => $acceptsMarketing, + ], + ]; + + if ($firstName) { + $payload['customer']['first_name'] = $firstName; + } + if ($lastName) { + $payload['customer']['last_name'] = $lastName; + } + if ($phone) { + $payload['customer']['phone'] = $phone; + } + if ($note) { + $payload['customer']['note'] = $note; + } + if (!empty($address)) { + $payload['customer']['address'] = $address; + } + + $response = $this->httpClient->request('POST', "https://{$this->shopDomain}.myshopify.com/admin/api/2023-10/customers.json", [ + 'headers' => [ + 'X-Shopify-Access-Token' => $this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error creating customer: '.implode(', ', $data['errors']); + } + + $customer = $data['customer']; + + return [ + 'id' => $customer['id'], + 'email' => $customer['email'], + 'accepts_marketing' => $customer['accepts_marketing'], + 'created_at' => $customer['created_at'], + 'updated_at' => $customer['updated_at'], + 'first_name' => $customer['first_name'] ?? '', + 'last_name' => $customer['last_name'] ?? '', + 'orders_count' => $customer['orders_count'] ?? 0, + 'state' => $customer['state'], + 'total_spent' => $customer['total_spent'], + 'note' => $customer['note'] ?? '', + 'verified_email' => $customer['verified_email'], + 'phone' => $customer['phone'] ?? '', + 'currency' => $customer['currency'], + 'admin_graphql_api_id' => $customer['admin_graphql_api_id'], + ]; + } catch (\Exception $e) { + return 'Error creating customer: '.$e->getMessage(); + } + } + + /** + * Update Shopify inventory. + * + * @param int $inventoryItemId Inventory item ID + * @param int $available Available quantity + * @param int $locationId Location ID + * + * @return array{ + * inventory_item_id: int, + * location_id: int, + * available: int, + * updated_at: string, + * }|string + */ + public function updateInventory( + int $inventoryItemId, + int $available, + int $locationId, + ): array|string { + try { + $payload = [ + 'location_id' => $locationId, + 'inventory_item_id' => $inventoryItemId, + 'available' => $available, + ]; + + $response = $this->httpClient->request('POST', "https://{$this->shopDomain}.myshopify.com/admin/api/2023-10/inventory_levels/set.json", [ + 'headers' => [ + 'X-Shopify-Access-Token' => $this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error updating inventory: '.implode(', ', $data['errors']); + } + + $inventoryLevel = $data['inventory_level']; + + return [ + 'inventory_item_id' => $inventoryLevel['inventory_item_id'], + 'location_id' => $inventoryLevel['location_id'], + 'available' => $inventoryLevel['available'], + 'updated_at' => $inventoryLevel['updated_at'], + ]; + } catch (\Exception $e) { + return 'Error updating inventory: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Slack.php b/src/agent/src/Toolbox/Tool/Slack.php new file mode 100644 index 000000000..6da4ec245 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Slack.php @@ -0,0 +1,402 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('slack_send_message', 'Tool that sends messages to Slack channels')] +#[AsTool('slack_get_messages', 'Tool that retrieves messages from Slack channels', method: 'getMessages')] +#[AsTool('slack_list_channels', 'Tool that lists Slack channels', method: 'listChannels')] +#[AsTool('slack_get_user_info', 'Tool that gets Slack user information', method: 'getUserInfo')] +#[AsTool('slack_upload_file', 'Tool that uploads files to Slack', method: 'uploadFile')] +final readonly class Slack +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $botToken, + private array $options = [], + ) { + } + + /** + * Send a message to a Slack channel. + * + * @param string $message The message to be sent + * @param string $channel The channel, private group, or IM channel to send message to + * @param array $blocks Optional rich formatting blocks + * @param string $threadTs Optional timestamp of parent message for threading + * + * @return array{ + * ok: bool, + * channel: string, + * ts: string, + * message: array, + * }|string + */ + public function __invoke( + #[With(maximum: 4000)] + string $message, + string $channel, + array $blocks = [], + string $threadTs = '', + ): array|string { + try { + $payload = [ + 'channel' => $channel, + 'text' => $message, + ]; + + if (!empty($blocks)) { + $payload['blocks'] = $blocks; + } + + if ($threadTs) { + $payload['thread_ts'] = $threadTs; + } + + $response = $this->httpClient->request('POST', 'https://slack.com/api/chat.postMessage', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->botToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (!$data['ok']) { + return 'Error sending message: '.($data['error'] ?? 'Unknown error'); + } + + return [ + 'ok' => $data['ok'], + 'channel' => $data['channel'], + 'ts' => $data['ts'], + 'message' => $data['message'], + ]; + } catch (\Exception $e) { + return 'Error sending message: '.$e->getMessage(); + } + } + + /** + * Get messages from a Slack channel. + * + * @param string $channelId The channel ID to get messages from + * @param int $limit Maximum number of messages to retrieve + * @param string $oldest Start of time range for messages (timestamp) + * @param string $latest End of time range for messages (timestamp) + * + * @return array, + * }>|string + */ + public function getMessages( + string $channelId, + int $limit = 100, + string $oldest = '', + string $latest = '', + ): array|string { + try { + $params = [ + 'channel' => $channelId, + 'limit' => $limit, + ]; + + if ($oldest) { + $params['oldest'] = $oldest; + } + if ($latest) { + $params['latest'] = $latest; + } + + $response = $this->httpClient->request('GET', 'https://slack.com/api/conversations.history', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->botToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!$data['ok']) { + return 'Error getting messages: '.($data['error'] ?? 'Unknown error'); + } + + $messages = []; + foreach ($data['messages'] as $message) { + if (isset($message['user']) && isset($message['text']) && isset($message['ts'])) { + $messages[] = [ + 'user' => $message['user'], + 'text' => $message['text'], + 'ts' => $message['ts'], + 'thread_ts' => $message['thread_ts'] ?? null, + 'replies' => $message['replies'] ?? [], + ]; + } + } + + return $messages; + } catch (\Exception $e) { + return 'Error getting messages: '.$e->getMessage(); + } + } + + /** + * List Slack channels. + * + * @param bool $excludeArchived Whether to exclude archived channels + * @param string $types Types of channels to list (public_channel, private_channel, mpim, im) + * @param int $limit Maximum number of channels to retrieve + * + * @return array|string + */ + public function listChannels( + bool $excludeArchived = true, + string $types = 'public_channel,private_channel', + int $limit = 100, + ): array|string { + try { + $response = $this->httpClient->request('GET', 'https://slack.com/api/conversations.list', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->botToken, + ], + 'query' => array_merge($this->options, [ + 'exclude_archived' => $excludeArchived, + 'types' => $types, + 'limit' => $limit, + ]), + ]); + + $data = $response->toArray(); + + if (!$data['ok']) { + return 'Error listing channels: '.($data['error'] ?? 'Unknown error'); + } + + $channels = []; + foreach ($data['channels'] as $channel) { + $channels[] = [ + 'id' => $channel['id'], + 'name' => $channel['name'], + 'is_channel' => $channel['is_channel'] ?? false, + 'is_group' => $channel['is_group'] ?? false, + 'is_im' => $channel['is_im'] ?? false, + 'is_private' => $channel['is_private'] ?? false, + 'is_archived' => $channel['is_archived'] ?? false, + 'is_member' => $channel['is_member'] ?? false, + 'num_members' => $channel['num_members'] ?? 0, + 'purpose' => $channel['purpose'] ?? ['value' => '', 'creator' => '', 'last_set' => 0], + 'topic' => $channel['topic'] ?? ['value' => '', 'creator' => '', 'last_set' => 0], + ]; + } + + return $channels; + } catch (\Exception $e) { + return 'Error listing channels: '.$e->getMessage(); + } + } + + /** + * Get Slack user information. + * + * @param string $userId User ID to get information for + * + * @return array{ + * id: string, + * name: string, + * real_name: string, + * display_name: string, + * profile: array{ + * title: string, + * phone: string, + * skype: string, + * real_name: string, + * real_name_normalized: string, + * display_name: string, + * display_name_normalized: string, + * email: string, + * image_24: string, + * image_32: string, + * image_48: string, + * image_72: string, + * image_192: string, + * image_512: string, + * status_text: string, + * status_emoji: string, + * }, + * is_admin: bool, + * is_owner: bool, + * is_bot: bool, + * is_app_user: bool, + * deleted: bool, + * }|string + */ + public function getUserInfo(string $userId): array|string + { + try { + $response = $this->httpClient->request('GET', 'https://slack.com/api/users.info', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->botToken, + ], + 'query' => array_merge($this->options, [ + 'user' => $userId, + ]), + ]); + + $data = $response->toArray(); + + if (!$data['ok']) { + return 'Error getting user info: '.($data['error'] ?? 'Unknown error'); + } + + $user = $data['user']; + + return [ + 'id' => $user['id'], + 'name' => $user['name'], + 'real_name' => $user['real_name'], + 'display_name' => $user['profile']['display_name'] ?? '', + 'profile' => [ + 'title' => $user['profile']['title'] ?? '', + 'phone' => $user['profile']['phone'] ?? '', + 'skype' => $user['profile']['skype'] ?? '', + 'real_name' => $user['profile']['real_name'] ?? '', + 'real_name_normalized' => $user['profile']['real_name_normalized'] ?? '', + 'display_name' => $user['profile']['display_name'] ?? '', + 'display_name_normalized' => $user['profile']['display_name_normalized'] ?? '', + 'email' => $user['profile']['email'] ?? '', + 'image_24' => $user['profile']['image_24'] ?? '', + 'image_32' => $user['profile']['image_32'] ?? '', + 'image_48' => $user['profile']['image_48'] ?? '', + 'image_72' => $user['profile']['image_72'] ?? '', + 'image_192' => $user['profile']['image_192'] ?? '', + 'image_512' => $user['profile']['image_512'] ?? '', + 'status_text' => $user['profile']['status_text'] ?? '', + 'status_emoji' => $user['profile']['status_emoji'] ?? '', + ], + 'is_admin' => $user['is_admin'] ?? false, + 'is_owner' => $user['is_owner'] ?? false, + 'is_bot' => $user['is_bot'] ?? false, + 'is_app_user' => $user['is_app_user'] ?? false, + 'deleted' => $user['deleted'] ?? false, + ]; + } catch (\Exception $e) { + return 'Error getting user info: '.$e->getMessage(); + } + } + + /** + * Upload a file to Slack. + * + * @param string $filePath Path to the file to upload + * @param string $channels Comma-separated list of channel names or IDs + * @param string $title Title of the file + * @param string $initialComment Initial comment to add to the file + * + * @return array{ + * ok: bool, + * file: array{ + * id: string, + * name: string, + * title: string, + * mimetype: string, + * filetype: string, + * pretty_type: string, + * user: string, + * size: int, + * url_private: string, + * url_private_download: string, + * permalink: string, + * permalink_public: string, + * }, + * }|string + */ + public function uploadFile( + string $filePath, + string $channels, + string $title = '', + string $initialComment = '', + ): array|string { + try { + if (!file_exists($filePath)) { + return 'Error: File does not exist'; + } + + $fileContent = file_get_contents($filePath); + $fileName = basename($filePath); + + $response = $this->httpClient->request('POST', 'https://slack.com/api/files.upload', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->botToken, + ], + 'body' => [ + 'channels' => $channels, + 'title' => $title ?: $fileName, + 'initial_comment' => $initialComment, + 'filename' => $fileName, + 'file' => $fileContent, + ], + ]); + + $data = $response->toArray(); + + if (!$data['ok']) { + return 'Error uploading file: '.($data['error'] ?? 'Unknown error'); + } + + return [ + 'ok' => $data['ok'], + 'file' => [ + 'id' => $data['file']['id'], + 'name' => $data['file']['name'], + 'title' => $data['file']['title'], + 'mimetype' => $data['file']['mimetype'], + 'filetype' => $data['file']['filetype'], + 'pretty_type' => $data['file']['pretty_type'], + 'user' => $data['file']['user'], + 'size' => $data['file']['size'], + 'url_private' => $data['file']['url_private'], + 'url_private_download' => $data['file']['url_private_download'], + 'permalink' => $data['file']['permalink'], + 'permalink_public' => $data['file']['permalink_public'], + ], + ]; + } catch (\Exception $e) { + return 'Error uploading file: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Sleep.php b/src/agent/src/Toolbox/Tool/Sleep.php new file mode 100644 index 000000000..281bb29b5 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Sleep.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +/** + * @author Mathieu Ledru + */ +#[AsTool('sleep', 'Tool that pauses execution for a specified duration')] +final readonly class Sleep +{ + /** + * Sleep for specified duration. + * + * @param int $seconds Duration in seconds + * @param int $milliseconds Additional milliseconds + * + * @return array{ + * success: bool, + * duration: float, + * message: string, + * } + */ + public function __invoke( + int $seconds = 1, + int $milliseconds = 0, + ): array { + $duration = $seconds + ($milliseconds / 1000); + + if ($duration <= 0) { + return [ + 'success' => false, + 'duration' => 0.0, + 'message' => 'Duration must be greater than 0', + ]; + } + + if ($duration > 300) { // 5 minutes max + return [ + 'success' => false, + 'duration' => $duration, + 'message' => 'Duration cannot exceed 300 seconds (5 minutes)', + ]; + } + + usleep((int) ($duration * 1000000)); + + return [ + 'success' => true, + 'duration' => $duration, + 'message' => "Slept for {$duration} seconds", + ]; + } +} diff --git a/src/agent/src/Toolbox/Tool/SparkSql.php b/src/agent/src/Toolbox/Tool/SparkSql.php new file mode 100644 index 000000000..9249eb2f3 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/SparkSql.php @@ -0,0 +1,640 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('spark_sql_execute_query', 'Tool that executes Spark SQL queries')] +#[AsTool('spark_sql_create_table', 'Tool that creates Spark SQL tables', method: 'createTable')] +#[AsTool('spark_sql_insert_data', 'Tool that inserts data into Spark SQL tables', method: 'insertData')] +#[AsTool('spark_sql_describe_table', 'Tool that describes Spark SQL table schema', method: 'describeTable')] +#[AsTool('spark_sql_show_tables', 'Tool that shows Spark SQL tables', method: 'showTables')] +#[AsTool('spark_sql_show_databases', 'Tool that shows Spark SQL databases', method: 'showDatabases')] +#[AsTool('spark_sql_optimize_table', 'Tool that optimizes Spark SQL tables', method: 'optimizeTable')] +#[AsTool('spark_sql_analyze_table', 'Tool that analyzes Spark SQL tables', method: 'analyzeTable')] +final readonly class SparkSql +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $host = 'localhost', + private int $port = 10000, + private string $database = 'default', + private string $username = '', + #[\SensitiveParameter] private string $password = '', + private array $options = [], + ) { + } + + /** + * Execute Spark SQL query. + * + * @param string $query SQL query to execute + * @param array $params Query parameters + * @param int $timeout Query timeout in seconds + * + * @return array{ + * success: bool, + * results: array>, + * columns: array, + * rowCount: int, + * executionTime: float, + * queryId: string, + * error: string, + * } + */ + public function __invoke( + string $query, + array $params = [], + int $timeout = 300, + ): array { + try { + $startTime = microtime(true); + + // This is a simplified implementation + // In reality, you would use a proper Spark SQL connector like JDBC or REST API + $command = $this->buildSparkSqlCommand($query, $params, $timeout); + $output = $this->executeCommand($command); + + $executionTime = microtime(true) - $startTime; + + // Parse output (simplified) + $results = $this->parseSparkSqlOutput($output); + + return [ + 'success' => true, + 'results' => $results['data'], + 'columns' => $results['columns'], + 'rowCount' => \count($results['data']), + 'executionTime' => $executionTime, + 'queryId' => $results['queryId'], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'results' => [], + 'columns' => [], + 'rowCount' => 0, + 'executionTime' => 0.0, + 'queryId' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create Spark SQL table. + * + * @param string $tableName Table name + * @param array $columns Table columns + * @param array $options Table options (format, location, etc.) + * @param string $database Database name + * + * @return array{ + * success: bool, + * table: string, + * database: string, + * message: string, + * error: string, + * } + */ + public function createTable( + string $tableName, + array $columns, + array $options = [], + string $database = '', + ): array { + try { + $database = $database ?: $this->database; + if (!$database) { + throw new \InvalidArgumentException('Database is required.'); + } + + $columnDefs = []; + foreach ($columns as $column) { + $nullable = $column['nullable'] ? '' : ' NOT NULL'; + $columnDefs[] = "{$column['name']} {$column['type']}{$nullable}"; + } + + $cql = "CREATE TABLE {$database}.{$tableName} (".implode(', ', $columnDefs).')'; + + // Add table options + if (!empty($options)) { + $optionPairs = []; + foreach ($options as $key => $value) { + $optionPairs[] = "'{$key}' = '{$value}'"; + } + $cql .= ' USING '.$options['format'] ?? 'parquet'; + $cql .= ' OPTIONS ('.implode(', ', $optionPairs).')'; + } + + $result = $this->__invoke($cql); + + return [ + 'success' => $result['success'], + 'table' => $tableName, + 'database' => $database, + 'message' => $result['success'] ? "Table '{$tableName}' created successfully" : 'Failed to create table', + 'error' => $result['error'], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'table' => $tableName, + 'database' => $database, + 'message' => 'Error creating table', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Insert data into Spark SQL table. + * + * @param string $tableName Table name + * @param array> $data Data to insert + * @param string $database Database name + * @param string $mode Insert mode (INSERT, INSERT_OVERWRITE, MERGE) + * + * @return array{ + * success: bool, + * table: string, + * database: string, + * rowsInserted: int, + * message: string, + * error: string, + * } + */ + public function insertData( + string $tableName, + array $data, + string $database = '', + string $mode = 'INSERT', + ): array { + try { + $database = $database ?: $this->database; + if (!$database) { + throw new \InvalidArgumentException('Database is required.'); + } + + if (empty($data)) { + return [ + 'success' => true, + 'table' => $tableName, + 'database' => $database, + 'rowsInserted' => 0, + 'message' => 'No data to insert', + 'error' => '', + ]; + } + + // Get column names from first row + $columns = array_keys($data[0]); + $columnList = implode(', ', $columns); + + // Build VALUES clause + $valueRows = []; + foreach ($data as $row) { + $values = []; + foreach ($columns as $column) { + $value = $row[$column] ?? 'NULL'; + if (\is_string($value)) { + $value = "'".addslashes($value)."'"; + } + $values[] = $value; + } + $valueRows[] = '('.implode(', ', $values).')'; + } + + $cql = "{$mode} INTO {$database}.{$tableName} ({$columnList}) VALUES ".implode(', ', $valueRows); + + $result = $this->__invoke($cql); + + return [ + 'success' => $result['success'], + 'table' => $tableName, + 'database' => $database, + 'rowsInserted' => \count($data), + 'message' => $result['success'] ? 'Data inserted successfully' : 'Failed to insert data', + 'error' => $result['error'], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'table' => $tableName, + 'database' => $database, + 'rowsInserted' => 0, + 'message' => 'Error inserting data', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Describe Spark SQL table schema. + * + * @param string $tableName Table name + * @param string $database Database name + * @param bool $extended Include extended information + * + * @return array{ + * success: bool, + * table: string, + * database: string, + * columns: array, + * partitions: array, + * storage: array{ + * location: string, + * inputFormat: string, + * outputFormat: string, + * serde: string, + * }, + * error: string, + * } + */ + public function describeTable( + string $tableName, + string $database = '', + bool $extended = false, + ): array { + try { + $database = $database ?: $this->database; + if (!$database) { + throw new \InvalidArgumentException('Database is required.'); + } + + $query = $extended ? "DESCRIBE EXTENDED {$database}.{$tableName}" : "DESCRIBE {$database}.{$tableName}"; + $result = $this->__invoke($query); + + if (!$result['success']) { + return [ + 'success' => false, + 'table' => $tableName, + 'database' => $database, + 'columns' => [], + 'partitions' => [], + 'storage' => ['location' => '', 'inputFormat' => '', 'outputFormat' => '', 'serde' => ''], + 'error' => $result['error'], + ]; + } + + // Parse column information (simplified) + $columns = []; + $partitions = []; + $storage = ['location' => '', 'inputFormat' => '', 'outputFormat' => '', 'serde' => '']; + + foreach ($result['results'] as $row) { + if (isset($row['col_name'])) { + $columns[] = [ + 'name' => $row['col_name'], + 'type' => $row['data_type'] ?? '', + 'nullable' => !str_contains(strtolower($row['data_type'] ?? ''), 'not null'), + 'comment' => $row['comment'] ?? '', + ]; + } + } + + return [ + 'success' => true, + 'table' => $tableName, + 'database' => $database, + 'columns' => $columns, + 'partitions' => $partitions, + 'storage' => $storage, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'table' => $tableName, + 'database' => $database, + 'columns' => [], + 'partitions' => [], + 'storage' => ['location' => '', 'inputFormat' => '', 'outputFormat' => '', 'serde' => ''], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Show Spark SQL tables. + * + * @param string $database Database name + * @param string $pattern Table name pattern + * + * @return array{ + * success: bool, + * database: string, + * tables: array, + * error: string, + * } + */ + public function showTables( + string $database = '', + string $pattern = '', + ): array { + try { + $database = $database ?: $this->database; + if (!$database) { + throw new \InvalidArgumentException('Database is required.'); + } + + $query = "SHOW TABLES FROM {$database}"; + if ($pattern) { + $query .= " LIKE '{$pattern}'"; + } + + $result = $this->__invoke($query); + + if (!$result['success']) { + return [ + 'success' => false, + 'database' => $database, + 'tables' => [], + 'error' => $result['error'], + ]; + } + + $tables = []; + foreach ($result['results'] as $row) { + $tables[] = [ + 'name' => $row['tableName'] ?? $row['name'] ?? '', + 'database' => $database, + 'tableType' => $row['tableType'] ?? 'MANAGED', + 'isTemporary' => str_contains($row['tableName'] ?? '', 'temp_'), + ]; + } + + return [ + 'success' => true, + 'database' => $database, + 'tables' => $tables, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'database' => $database, + 'tables' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Show Spark SQL databases. + * + * @param string $pattern Database name pattern + * + * @return array{ + * success: bool, + * databases: array, + * error: string, + * } + */ + public function showDatabases(string $pattern = ''): array + { + try { + $query = 'SHOW DATABASES'; + if ($pattern) { + $query .= " LIKE '{$pattern}'"; + } + + $result = $this->__invoke($query); + + if (!$result['success']) { + return [ + 'success' => false, + 'databases' => [], + 'error' => $result['error'], + ]; + } + + $databases = []; + foreach ($result['results'] as $row) { + $databases[] = [ + 'name' => $row['namespace'] ?? $row['databaseName'] ?? $row['name'] ?? '', + 'description' => $row['description'] ?? '', + 'locationUri' => $row['locationUri'] ?? '', + ]; + } + + return [ + 'success' => true, + 'databases' => $databases, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'databases' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Optimize Spark SQL table. + * + * @param string $tableName Table name + * @param string $database Database name + * @param array $columns Columns to optimize (optional) + * + * @return array{ + * success: bool, + * table: string, + * database: string, + * message: string, + * error: string, + * } + */ + public function optimizeTable( + string $tableName, + string $database = '', + array $columns = [], + ): array { + try { + $database = $database ?: $this->database; + if (!$database) { + throw new \InvalidArgumentException('Database is required.'); + } + + $query = "OPTIMIZE {$database}.{$tableName}"; + if (!empty($columns)) { + $columnList = implode(', ', $columns); + $query .= " ZORDER BY ({$columnList})"; + } + + $result = $this->__invoke($query); + + return [ + 'success' => $result['success'], + 'table' => $tableName, + 'database' => $database, + 'message' => $result['success'] ? "Table '{$tableName}' optimized successfully" : 'Failed to optimize table', + 'error' => $result['error'], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'table' => $tableName, + 'database' => $database, + 'message' => 'Error optimizing table', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze Spark SQL table. + * + * @param string $tableName Table name + * @param string $database Database name + * @param array $columns Columns to analyze (optional) + * + * @return array{ + * success: bool, + * table: string, + * database: string, + * statistics: array{ + * rowCount: int, + * sizeInBytes: int, + * columnStats: array, + * }, + * message: string, + * error: string, + * } + */ + public function analyzeTable( + string $tableName, + string $database = '', + array $columns = [], + ): array { + try { + $database = $database ?: $this->database; + if (!$database) { + throw new \InvalidArgumentException('Database is required.'); + } + + $query = "ANALYZE TABLE {$database}.{$tableName} COMPUTE STATISTICS"; + if (!empty($columns)) { + $columnList = implode(', ', $columns); + $query .= " FOR COLUMNS {$columnList}"; + } + + $result = $this->__invoke($query); + + return [ + 'success' => $result['success'], + 'table' => $tableName, + 'database' => $database, + 'statistics' => [ + 'rowCount' => 0, + 'sizeInBytes' => 0, + 'columnStats' => [], + ], + 'message' => $result['success'] ? "Table '{$tableName}' analyzed successfully" : 'Failed to analyze table', + 'error' => $result['error'], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'table' => $tableName, + 'database' => $database, + 'statistics' => [ + 'rowCount' => 0, + 'sizeInBytes' => 0, + 'columnStats' => [], + ], + 'message' => 'Error analyzing table', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Build Spark SQL command. + */ + private function buildSparkSqlCommand(string $query, array $params, int $timeout): string + { + $command = "spark-sql --master yarn --conf spark.sql.execution.timeout={$timeout}s"; + + if ($this->database) { + $command .= " --database {$this->database}"; + } + + $command .= ' -e "'.addslashes($query).'"'; + + return $command; + } + + /** + * Execute command. + */ + private function executeCommand(string $command): string + { + $output = []; + $returnCode = 0; + + exec("{$command} 2>&1", $output, $returnCode); + + if (0 !== $returnCode) { + throw new \RuntimeException('Spark SQL command failed: '.implode("\n", $output)); + } + + return implode("\n", $output); + } + + /** + * Parse Spark SQL output. + */ + private function parseSparkSqlOutput(string $output): array + { + // This is a simplified parser + // In reality, you would need more sophisticated parsing + return [ + 'data' => [], + 'columns' => [], + 'queryId' => uniqid('spark_', true), + ]; + } +} diff --git a/src/agent/src/Toolbox/Tool/Spotify.php b/src/agent/src/Toolbox/Tool/Spotify.php new file mode 100644 index 000000000..228b4caee --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Spotify.php @@ -0,0 +1,591 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('spotify_search', 'Tool that searches for tracks, artists, albums, and playlists on Spotify')] +#[AsTool('spotify_get_track', 'Tool that gets detailed information about a Spotify track', method: 'getTrack')] +#[AsTool('spotify_get_artist', 'Tool that gets detailed information about a Spotify artist', method: 'getArtist')] +#[AsTool('spotify_get_album', 'Tool that gets detailed information about a Spotify album', method: 'getAlbum')] +#[AsTool('spotify_get_playlist', 'Tool that gets detailed information about a Spotify playlist', method: 'getPlaylist')] +#[AsTool('spotify_get_user_profile', 'Tool that gets Spotify user profile information', method: 'getUserProfile')] +final readonly class Spotify +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $clientId, + #[\SensitiveParameter] private string $clientSecret, + private array $options = [], + ) { + } + + /** + * Search for tracks, artists, albums, and playlists on Spotify. + * + * @param string $query Search query + * @param string $type Comma-separated list of item types: album, artist, playlist, track + * @param int $limit Maximum number of results to return (1-50) + * @param int $offset Index of the first result to return + * @param string $market ISO 3166-1 alpha-2 country code + * + * @return array{ + * tracks: array, + * album: array{ + * id: string, + * name: string, + * images: array, + * release_date: string, + * }, + * duration_ms: int, + * explicit: bool, + * popularity: int, + * preview_url: string|null, + * external_urls: array{spotify: string}, + * }>, + * artists: array, + * popularity: int, + * followers: array{total: int}, + * images: array, + * external_urls: array{spotify: string}, + * }>, + * albums: array, + * images: array, + * release_date: string, + * total_tracks: int, + * album_type: string, + * external_urls: array{spotify: string}, + * }>, + * playlists: array, + * external_urls: array{spotify: string}, + * }>, + * } + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + string $type = 'track,artist,album,playlist', + int $limit = 20, + int $offset = 0, + string $market = 'US', + ): array { + try { + $accessToken = $this->getAccessToken(); + if (!$accessToken) { + return [ + 'tracks' => [], + 'artists' => [], + 'albums' => [], + 'playlists' => [], + ]; + } + + $response = $this->httpClient->request('GET', 'https://api.spotify.com/v1/search', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + ], + 'query' => array_merge($this->options, [ + 'q' => $query, + 'type' => $type, + 'limit' => min(max($limit, 1), 50), + 'offset' => max($offset, 0), + 'market' => $market, + ]), + ]); + + $data = $response->toArray(); + + return [ + 'tracks' => $this->formatTracks($data['tracks']['items'] ?? []), + 'artists' => $this->formatArtists($data['artists']['items'] ?? []), + 'albums' => $this->formatAlbums($data['albums']['items'] ?? []), + 'playlists' => $this->formatPlaylists($data['playlists']['items'] ?? []), + ]; + } catch (\Exception $e) { + return [ + 'tracks' => [], + 'artists' => [], + 'albums' => [], + 'playlists' => [], + ]; + } + } + + /** + * Get detailed information about a Spotify track. + * + * @param string $trackId Track ID + * + * @return array{ + * id: string, + * name: string, + * artists: array, popularity: int}>, + * album: array{ + * id: string, + * name: string, + * images: array, + * release_date: string, + * total_tracks: int, + * album_type: string, + * }, + * duration_ms: int, + * explicit: bool, + * popularity: int, + * preview_url: string|null, + * external_urls: array{spotify: string}, + * audio_features: array|null, + * }|string + */ + public function getTrack(string $trackId): array|string + { + try { + $accessToken = $this->getAccessToken(); + if (!$accessToken) { + return 'Access token required'; + } + + $response = $this->httpClient->request('GET', "https://api.spotify.com/v1/tracks/{$trackId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + ], + ]); + + $trackData = $response->toArray(); + + // Get audio features + $audioFeaturesResponse = $this->httpClient->request('GET', "https://api.spotify.com/v1/audio-features/{$trackId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + ], + ]); + + $audioFeatures = $audioFeaturesResponse->toArray(); + + return [ + 'id' => $trackData['id'], + 'name' => $trackData['name'], + 'artists' => array_map(fn ($artist) => [ + 'id' => $artist['id'], + 'name' => $artist['name'], + 'genres' => $artist['genres'] ?? [], + 'popularity' => $artist['popularity'] ?? 0, + ], $trackData['artists']), + 'album' => [ + 'id' => $trackData['album']['id'], + 'name' => $trackData['album']['name'], + 'images' => $trackData['album']['images'], + 'release_date' => $trackData['album']['release_date'], + 'total_tracks' => $trackData['album']['total_tracks'], + 'album_type' => $trackData['album']['album_type'], + ], + 'duration_ms' => $trackData['duration_ms'], + 'explicit' => $trackData['explicit'], + 'popularity' => $trackData['popularity'], + 'preview_url' => $trackData['preview_url'], + 'external_urls' => $trackData['external_urls'], + 'audio_features' => $audioFeatures, + ]; + } catch (\Exception $e) { + return 'Error getting track: '.$e->getMessage(); + } + } + + /** + * Get detailed information about a Spotify artist. + * + * @param string $artistId Artist ID + * + * @return array{ + * id: string, + * name: string, + * genres: array, + * popularity: int, + * followers: array{total: int}, + * images: array, + * external_urls: array{spotify: string}, + * top_tracks: array, + * albums: array, + * }|string + */ + public function getArtist(string $artistId): array|string + { + try { + $accessToken = $this->getAccessToken(); + if (!$accessToken) { + return 'Access token required'; + } + + $response = $this->httpClient->request('GET', "https://api.spotify.com/v1/artists/{$artistId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + ], + ]); + + $artistData = $response->toArray(); + + // Get top tracks + $topTracksResponse = $this->httpClient->request('GET', "https://api.spotify.com/v1/artists/{$artistId}/top-tracks", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + ], + 'query' => ['market' => 'US'], + ]); + + $topTracks = $topTracksResponse->toArray(); + + // Get albums + $albumsResponse = $this->httpClient->request('GET', "https://api.spotify.com/v1/artists/{$artistId}/albums", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + ], + 'query' => ['limit' => 10], + ]); + + $albums = $albumsResponse->toArray(); + + return [ + 'id' => $artistData['id'], + 'name' => $artistData['name'], + 'genres' => $artistData['genres'], + 'popularity' => $artistData['popularity'], + 'followers' => $artistData['followers'], + 'images' => $artistData['images'], + 'external_urls' => $artistData['external_urls'], + 'top_tracks' => array_map(fn ($track) => [ + 'id' => $track['id'], + 'name' => $track['name'], + 'popularity' => $track['popularity'], + ], $topTracks['tracks'] ?? []), + 'albums' => array_map(fn ($album) => [ + 'id' => $album['id'], + 'name' => $album['name'], + 'album_type' => $album['album_type'], + ], $albums['items'] ?? []), + ]; + } catch (\Exception $e) { + return 'Error getting artist: '.$e->getMessage(); + } + } + + /** + * Get detailed information about a Spotify album. + * + * @param string $albumId Album ID + * + * @return array{ + * id: string, + * name: string, + * artists: array, + * images: array, + * release_date: string, + * total_tracks: int, + * album_type: string, + * popularity: int, + * external_urls: array{spotify: string}, + * tracks: array, + * }|string + */ + public function getAlbum(string $albumId): array|string + { + try { + $accessToken = $this->getAccessToken(); + if (!$accessToken) { + return 'Access token required'; + } + + $response = $this->httpClient->request('GET', "https://api.spotify.com/v1/albums/{$albumId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + ], + ]); + + $albumData = $response->toArray(); + + return [ + 'id' => $albumData['id'], + 'name' => $albumData['name'], + 'artists' => array_map(fn ($artist) => [ + 'id' => $artist['id'], + 'name' => $artist['name'], + ], $albumData['artists']), + 'images' => $albumData['images'], + 'release_date' => $albumData['release_date'], + 'total_tracks' => $albumData['total_tracks'], + 'album_type' => $albumData['album_type'], + 'popularity' => $albumData['popularity'] ?? 0, + 'external_urls' => $albumData['external_urls'], + 'tracks' => array_map(fn ($track) => [ + 'id' => $track['id'], + 'name' => $track['name'], + 'duration_ms' => $track['duration_ms'], + 'track_number' => $track['track_number'], + ], $albumData['tracks']['items']), + ]; + } catch (\Exception $e) { + return 'Error getting album: '.$e->getMessage(); + } + } + + /** + * Get detailed information about a Spotify playlist. + * + * @param string $playlistId Playlist ID + * + * @return array{ + * id: string, + * name: string, + * description: string, + * owner: array{id: string, display_name: string}, + * tracks: array{total: int, items: array}>}, + * images: array, + * external_urls: array{spotify: string}, + * public: bool, + * collaborative: bool, + * followers: array{total: int}, + * }|string + */ + public function getPlaylist(string $playlistId): array|string + { + try { + $accessToken = $this->getAccessToken(); + if (!$accessToken) { + return 'Access token required'; + } + + $response = $this->httpClient->request('GET', "https://api.spotify.com/v1/playlists/{$playlistId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + ], + 'query' => [ + 'market' => 'US', + ], + ]); + + $playlistData = $response->toArray(); + + return [ + 'id' => $playlistData['id'], + 'name' => $playlistData['name'], + 'description' => $playlistData['description'] ?? '', + 'owner' => [ + 'id' => $playlistData['owner']['id'], + 'display_name' => $playlistData['owner']['display_name'], + ], + 'tracks' => [ + 'total' => $playlistData['tracks']['total'], + 'items' => \array_slice($playlistData['tracks']['items'], 0, 10), // Limit to first 10 tracks + ], + 'images' => $playlistData['images'], + 'external_urls' => $playlistData['external_urls'], + 'public' => $playlistData['public'], + 'collaborative' => $playlistData['collaborative'], + 'followers' => $playlistData['followers'], + ]; + } catch (\Exception $e) { + return 'Error getting playlist: '.$e->getMessage(); + } + } + + /** + * Get Spotify user profile information. + * + * @param string $userId User ID (optional, defaults to current user) + * + * @return array{ + * id: string, + * display_name: string, + * email: string, + * country: string, + * followers: array{total: int}, + * images: array, + * product: string, + * external_urls: array{spotify: string}, + * }|string + */ + public function getUserProfile(string $userId = ''): array|string + { + try { + $accessToken = $this->getAccessToken(); + if (!$accessToken) { + return 'Access token required'; + } + + $endpoint = $userId ? "https://api.spotify.com/v1/users/{$userId}" : 'https://api.spotify.com/v1/me'; + + $response = $this->httpClient->request('GET', $endpoint, [ + 'headers' => [ + 'Authorization' => 'Bearer '.$accessToken, + ], + ]); + + $userData = $response->toArray(); + + return [ + 'id' => $userData['id'], + 'display_name' => $userData['display_name'] ?? '', + 'email' => $userData['email'] ?? '', + 'country' => $userData['country'] ?? '', + 'followers' => $userData['followers'] ?? ['total' => 0], + 'images' => $userData['images'] ?? [], + 'product' => $userData['product'] ?? '', + 'external_urls' => $userData['external_urls'], + ]; + } catch (\Exception $e) { + return 'Error getting user profile: '.$e->getMessage(); + } + } + + /** + * Get access token using Client Credentials flow. + */ + private function getAccessToken(): ?string + { + try { + $response = $this->httpClient->request('POST', 'https://accounts.spotify.com/api/token', [ + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => http_build_query([ + 'grant_type' => 'client_credentials', + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + ]), + ]); + + $data = $response->toArray(); + + return $data['access_token'] ?? null; + } catch (\Exception $e) { + return null; + } + } + + /** + * Format tracks data. + * + * @param array> $tracks + * + * @return array> + */ + private function formatTracks(array $tracks): array + { + return array_map(fn ($track) => [ + 'id' => $track['id'], + 'name' => $track['name'], + 'artists' => array_map(fn ($artist) => [ + 'id' => $artist['id'], + 'name' => $artist['name'], + ], $track['artists']), + 'album' => [ + 'id' => $track['album']['id'], + 'name' => $track['album']['name'], + 'images' => $track['album']['images'], + 'release_date' => $track['album']['release_date'], + ], + 'duration_ms' => $track['duration_ms'], + 'explicit' => $track['explicit'], + 'popularity' => $track['popularity'], + 'preview_url' => $track['preview_url'], + 'external_urls' => $track['external_urls'], + ], $tracks); + } + + /** + * Format artists data. + * + * @param array> $artists + * + * @return array> + */ + private function formatArtists(array $artists): array + { + return array_map(fn ($artist) => [ + 'id' => $artist['id'], + 'name' => $artist['name'], + 'genres' => $artist['genres'], + 'popularity' => $artist['popularity'], + 'followers' => $artist['followers'], + 'images' => $artist['images'], + 'external_urls' => $artist['external_urls'], + ], $artists); + } + + /** + * Format albums data. + * + * @param array> $albums + * + * @return array> + */ + private function formatAlbums(array $albums): array + { + return array_map(fn ($album) => [ + 'id' => $album['id'], + 'name' => $album['name'], + 'artists' => array_map(fn ($artist) => [ + 'id' => $artist['id'], + 'name' => $artist['name'], + ], $album['artists']), + 'images' => $album['images'], + 'release_date' => $album['release_date'], + 'total_tracks' => $album['total_tracks'], + 'album_type' => $album['album_type'], + 'external_urls' => $album['external_urls'], + ], $albums); + } + + /** + * Format playlists data. + * + * @param array> $playlists + * + * @return array> + */ + private function formatPlaylists(array $playlists): array + { + return array_map(fn ($playlist) => [ + 'id' => $playlist['id'], + 'name' => $playlist['name'], + 'description' => $playlist['description'] ?? '', + 'owner' => [ + 'id' => $playlist['owner']['id'], + 'display_name' => $playlist['owner']['display_name'], + ], + 'tracks' => ['total' => $playlist['tracks']['total']], + 'images' => $playlist['images'], + 'external_urls' => $playlist['external_urls'], + ], $playlists); + } +} diff --git a/src/agent/src/Toolbox/Tool/SqlDatabase.php b/src/agent/src/Toolbox/Tool/SqlDatabase.php new file mode 100644 index 000000000..c0175b024 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/SqlDatabase.php @@ -0,0 +1,231 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +// Note: Requires doctrine/dbal package +// use Doctrine\DBAL\Connection; +// use Doctrine\DBAL\DriverManager; +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; + +/** + * @author Mathieu Ledru + */ +#[AsTool('sql_db_query', 'Tool for executing SQL queries against a database')] +#[AsTool('sql_db_schema', 'Tool for getting database schema information', method: 'getSchema')] +#[AsTool('sql_db_list_tables', 'Tool for listing all tables in the database', method: 'listTables')] +final readonly class SqlDatabase +{ + public function __construct( + private mixed $connection, // PDO or Doctrine DBAL Connection + private int $sampleRowsLimit = 3, + ) { + } + + /** + * Execute a SQL query against the database. + * + * @param string $query A detailed and correct SQL query + * + * @return array>|string + */ + public function __invoke(string $query): array|string + { + try { + // Basic query validation + if (!$this->isValidQuery($query)) { + return 'Error: Invalid or potentially dangerous query detected. Only SELECT queries are allowed.'; + } + + $result = $this->connection->executeQuery($query); + $rows = $result->fetchAllAssociative(); + + return $rows; + } catch (\Exception $e) { + return 'Error executing query: '.$e->getMessage(); + } + } + + /** + * Get schema information for specified tables. + * + * @param string $tableNames Comma-separated list of table names + */ + public function getSchema(string $tableNames): string + { + try { + $tables = array_map('trim', explode(',', $tableNames)); + $schema = []; + + foreach ($tables as $tableName) { + $tableInfo = $this->getTableInfo($tableName); + if ($tableInfo) { + $schema[] = $tableInfo; + } + } + + return implode("\n\n", $schema); + } catch (\Exception $e) { + return 'Error getting schema: '.$e->getMessage(); + } + } + + /** + * List all tables in the database. + * + * @return array + */ + public function listTables(): array + { + try { + $sql = match ($this->connection->getDatabasePlatform()->getName()) { + 'mysql' => 'SHOW TABLES', + 'postgresql' => 'SELECT table_name FROM information_schema.tables WHERE table_schema = \'public\'', + 'sqlite' => 'SELECT name FROM sqlite_master WHERE type = \'table\'', + default => 'SELECT table_name FROM information_schema.tables', + }; + + $result = $this->connection->executeQuery($sql); + $tables = $result->fetchFirstColumn(); + + return $tables; + } catch (\Exception $e) { + return ['Error: '.$e->getMessage()]; + } + } + + /** + * Create a new SQL Database tool instance from PDO connection. + */ + public static function fromPdo(\PDO $pdo): self + { + return new self($pdo); + } + + /** + * Get detailed information about a table. + */ + private function getTableInfo(string $tableName): ?string + { + try { + // Get table schema + $schema = $this->getTableSchema($tableName); + + // Get sample rows + $sampleRows = $this->getSampleRows($tableName); + + // Get row count + $rowCount = $this->getRowCount($tableName); + + $info = "Table: {$tableName}\n"; + $info .= "Row count: {$rowCount}\n\n"; + $info .= "Schema:\n{$schema}\n\n"; + + if (!empty($sampleRows)) { + $info .= "Sample rows:\n"; + foreach ($sampleRows as $row) { + $info .= json_encode($row, \JSON_PRETTY_PRINT)."\n"; + } + } + + return $info; + } catch (\Exception $e) { + return null; + } + } + + /** + * Get table schema information. + */ + private function getTableSchema(string $tableName): string + { + try { + $sql = match ($this->connection->getDatabasePlatform()->getName()) { + 'mysql' => "DESCRIBE `{$tableName}`", + 'postgresql' => "SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = '{$tableName}' ORDER BY ordinal_position", + 'sqlite' => "PRAGMA table_info(`{$tableName}`)", + default => "SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = '{$tableName}' ORDER BY ordinal_position", + }; + + $result = $this->connection->executeQuery($sql); + $columns = $result->fetchAllAssociative(); + + $schema = ''; + foreach ($columns as $column) { + $schema .= json_encode($column, \JSON_PRETTY_PRINT)."\n"; + } + + return $schema; + } catch (\Exception $e) { + return 'Error getting schema: '.$e->getMessage(); + } + } + + /** + * Get sample rows from a table. + * + * @return array> + */ + private function getSampleRows(string $tableName): array + { + try { + $sql = "SELECT * FROM `{$tableName}` LIMIT {$this->sampleRowsLimit}"; + $result = $this->connection->executeQuery($sql); + + return $result->fetchAllAssociative(); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get row count for a table. + */ + private function getRowCount(string $tableName): int + { + try { + $sql = "SELECT COUNT(*) FROM `{$tableName}`"; + $result = $this->connection->executeQuery($sql); + + return (int) $result->fetchOne(); + } catch (\Exception $e) { + return 0; + } + } + + /** + * Validate SQL query for safety. + */ + private function isValidQuery(string $query): bool + { + $query = trim($query); + + // Only allow SELECT queries + if (!preg_match('/^\s*SELECT\s+/i', $query)) { + return false; + } + + // Check for potentially dangerous keywords + $dangerousKeywords = [ + 'DROP', 'DELETE', 'INSERT', 'UPDATE', 'ALTER', 'CREATE', 'TRUNCATE', + 'EXEC', 'EXECUTE', 'UNION', '--', '/*', '*/', ';', 'xp_', 'sp_', + ]; + + $upperQuery = strtoupper($query); + foreach ($dangerousKeywords as $keyword) { + if (str_contains($upperQuery, $keyword)) { + return false; + } + } + + return true; + } +} diff --git a/src/agent/src/Toolbox/Tool/StackExchange.php b/src/agent/src/Toolbox/Tool/StackExchange.php new file mode 100644 index 000000000..0dfb6ac29 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/StackExchange.php @@ -0,0 +1,330 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('stack_exchange', 'Tool that searches StackExchange for programming questions and answers')] +#[AsTool('stack_exchange_questions', 'Tool that gets questions from StackExchange', method: 'getQuestions')] +#[AsTool('stack_exchange_answers', 'Tool that gets answers for a specific question', method: 'getAnswers')] +final readonly class StackExchange +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private ?string $apiKey = null, + private string $site = 'stackoverflow', + private array $options = [], + ) { + } + + /** + * Search StackExchange for programming questions and answers. + * + * @param string $query search query for programming questions + * @param int $limit Maximum number of results to return + * @param string $sort Sort order: "relevance", "activity", "votes", "creation", "hot", "week", "month" + * + * @return array, + * link: string, + * }> + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + int $limit = 10, + string $sort = 'relevance', + ): array { + try { + $response = $this->httpClient->request('GET', 'https://api.stackexchange.com/2.3/search/advanced', [ + 'query' => array_merge($this->options, [ + 'q' => $query, + 'site' => $this->site, + 'sort' => $sort, + 'order' => 'desc', + 'pagesize' => $limit, + 'filter' => 'withbody', + 'key' => $this->apiKey, + ]), + ]); + + $data = $response->toArray(); + + if (!isset($data['items'])) { + return []; + } + + $results = []; + foreach ($data['items'] as $question) { + $results[] = [ + 'question_id' => $question['question_id'], + 'title' => $question['title'], + 'body' => $this->cleanHtml($question['body']), + 'score' => $question['score'], + 'view_count' => $question['view_count'], + 'answer_count' => $question['answer_count'], + 'accepted_answer_id' => $question['accepted_answer_id'] ?? null, + 'creation_date' => date('Y-m-d H:i:s', $question['creation_date']), + 'last_activity_date' => date('Y-m-d H:i:s', $question['last_activity_date']), + 'owner' => [ + 'display_name' => $question['owner']['display_name'] ?? 'Unknown', + 'reputation' => $question['owner']['reputation'] ?? 0, + ], + 'tags' => $question['tags'] ?? [], + 'link' => $question['link'], + ]; + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'question_id' => 0, + 'title' => 'Search Error', + 'body' => 'Unable to search StackExchange: '.$e->getMessage(), + 'score' => 0, + 'view_count' => 0, + 'answer_count' => 0, + 'accepted_answer_id' => null, + 'creation_date' => '', + 'last_activity_date' => '', + 'owner' => ['display_name' => '', 'reputation' => 0], + 'tags' => [], + 'link' => '', + ], + ]; + } + } + + /** + * Get questions from StackExchange. + * + * @param string $tag Tag to filter questions by (optional) + * @param int $limit Maximum number of results to return + * @param string $sort Sort order: "activity", "votes", "creation", "hot", "week", "month" + * @param string $timeFilter Time filter: "all", "week", "month", "quarter", "year" + * + * @return array, + * link: string, + * }> + */ + public function getQuestions( + string $tag = '', + int $limit = 10, + string $sort = 'activity', + string $timeFilter = 'all', + ): array { + try { + $params = [ + 'site' => $this->site, + 'sort' => $sort, + 'order' => 'desc', + 'pagesize' => $limit, + 'filter' => 'withbody', + 'key' => $this->apiKey, + ]; + + if ($tag) { + $params['tagged'] = $tag; + } + + if ('all' !== $timeFilter) { + $params['fromdate'] = $this->getTimestampForFilter($timeFilter); + } + + $response = $this->httpClient->request('GET', 'https://api.stackexchange.com/2.3/questions', [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['items'])) { + return []; + } + + $results = []; + foreach ($data['items'] as $question) { + $results[] = [ + 'question_id' => $question['question_id'], + 'title' => $question['title'], + 'body' => $this->cleanHtml($question['body']), + 'score' => $question['score'], + 'view_count' => $question['view_count'], + 'answer_count' => $question['answer_count'], + 'accepted_answer_id' => $question['accepted_answer_id'] ?? null, + 'creation_date' => date('Y-m-d H:i:s', $question['creation_date']), + 'last_activity_date' => date('Y-m-d H:i:s', $question['last_activity_date']), + 'owner' => [ + 'display_name' => $question['owner']['display_name'] ?? 'Unknown', + 'reputation' => $question['owner']['reputation'] ?? 0, + ], + 'tags' => $question['tags'] ?? [], + 'link' => $question['link'], + ]; + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'question_id' => 0, + 'title' => 'Error', + 'body' => 'Unable to get questions: '.$e->getMessage(), + 'score' => 0, + 'view_count' => 0, + 'answer_count' => 0, + 'accepted_answer_id' => null, + 'creation_date' => '', + 'last_activity_date' => '', + 'owner' => ['display_name' => '', 'reputation' => 0], + 'tags' => [], + 'link' => '', + ], + ]; + } + } + + /** + * Get answers for a specific question. + * + * @param int $questionId The question ID + * @param string $sort Sort order: "activity", "creation", "votes" + * @param int $limit Maximum number of results to return + * + * @return array + */ + public function getAnswers(int $questionId, string $sort = 'votes', int $limit = 10): array + { + try { + $response = $this->httpClient->request('GET', "https://api.stackexchange.com/2.3/questions/{$questionId}/answers", [ + 'query' => array_merge($this->options, [ + 'site' => $this->site, + 'sort' => $sort, + 'order' => 'desc', + 'pagesize' => $limit, + 'filter' => 'withbody', + 'key' => $this->apiKey, + ]), + ]); + + $data = $response->toArray(); + + if (!isset($data['items'])) { + return []; + } + + $results = []; + foreach ($data['items'] as $answer) { + $results[] = [ + 'answer_id' => $answer['answer_id'], + 'question_id' => $answer['question_id'], + 'body' => $this->cleanHtml($answer['body']), + 'score' => $answer['score'], + 'is_accepted' => $answer['is_accepted'], + 'creation_date' => date('Y-m-d H:i:s', $answer['creation_date']), + 'last_activity_date' => date('Y-m-d H:i:s', $answer['last_activity_date']), + 'owner' => [ + 'display_name' => $answer['owner']['display_name'] ?? 'Unknown', + 'reputation' => $answer['owner']['reputation'] ?? 0, + ], + 'link' => $answer['link'], + ]; + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'answer_id' => 0, + 'question_id' => $questionId, + 'body' => 'Unable to get answers: '.$e->getMessage(), + 'score' => 0, + 'is_accepted' => false, + 'creation_date' => '', + 'last_activity_date' => '', + 'owner' => ['display_name' => '', 'reputation' => 0], + 'link' => '', + ], + ]; + } + } + + /** + * Clean HTML from StackExchange content. + */ + private function cleanHtml(string $html): string + { + // Remove HTML tags and decode entities + $text = strip_tags($html); + $text = html_entity_decode($text, \ENT_QUOTES | \ENT_HTML5, 'UTF-8'); + + // Clean up extra whitespace + $text = preg_replace('/\s+/', ' ', $text); + + return trim($text); + } + + /** + * Get timestamp for time filter. + */ + private function getTimestampForFilter(string $filter): int + { + $now = time(); + + return match ($filter) { + 'week' => $now - (7 * 24 * 60 * 60), + 'month' => $now - (30 * 24 * 60 * 60), + 'quarter' => $now - (90 * 24 * 60 * 60), + 'year' => $now - (365 * 24 * 60 * 60), + default => 0, + }; + } +} diff --git a/src/agent/src/Toolbox/Tool/Steam.php b/src/agent/src/Toolbox/Tool/Steam.php new file mode 100644 index 000000000..b58087fcb --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Steam.php @@ -0,0 +1,398 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('steam_game_search', 'Tool that searches for games on Steam')] +#[AsTool('steam_user_profile', 'Tool that gets Steam user profile information', method: 'getUserProfile')] +#[AsTool('steam_game_details', 'Tool that gets detailed information about a Steam game', method: 'getGameDetails')] +#[AsTool('steam_user_games', 'Tool that gets a user\'s game library', method: 'getUserGames')] +final readonly class Steam +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private ?string $apiKey = null, + private array $options = [], + ) { + } + + /** + * Search for games on Steam. + * + * @param string $query Game name to search for + * @param int $maxResults Maximum number of results to return + * @param string $category Category filter (optional) + * + * @return array, + * genres: array, + * steam_url: string, + * }> + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + int $maxResults = 20, + string $category = '', + ): array { + try { + // Note: Steam Store API doesn't require API key for basic searches + $params = [ + 'term' => $query, + 'l' => 'english', + 'cc' => 'us', + ]; + + if ($category) { + $params['category1'] = $category; + } + + $response = $this->httpClient->request('GET', 'https://store.steampowered.com/api/storesearch', [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['items'])) { + return []; + } + + $results = []; + $count = 0; + + foreach ($data['items'] as $item) { + if ($count >= $maxResults) { + break; + } + + $results[] = [ + 'app_id' => $item['id'], + 'name' => $item['name'], + 'short_description' => $item['tiny_description'] ?? '', + 'header_image' => $item['header_image'] ?? '', + 'capsule_image' => $item['capsule_image'] ?? '', + 'price_overview' => [ + 'currency' => $item['currency'] ?? 'USD', + 'initial' => $item['original_price'] ?? 0, + 'final' => $item['final_price'] ?? 0, + 'discount_percent' => $item['discount_percent'] ?? 0, + ], + 'release_date' => [ + 'coming_soon' => $item['coming_soon'] ?? false, + 'date' => $item['release_date'] ?? '', + ], + 'categories' => $this->formatCategories($item['categories'] ?? []), + 'genres' => $this->formatGenres($item['genres'] ?? []), + 'steam_url' => $item['url'] ?? '', + ]; + + ++$count; + } + + return $results; + } catch (\Exception $e) { + return [ + [ + 'app_id' => 0, + 'name' => 'Search Error', + 'short_description' => 'Unable to search Steam games: '.$e->getMessage(), + 'header_image' => '', + 'capsule_image' => '', + 'price_overview' => ['currency' => 'USD', 'initial' => 0, 'final' => 0, 'discount_percent' => 0], + 'release_date' => ['coming_soon' => false, 'date' => ''], + 'categories' => [], + 'genres' => [], + 'steam_url' => '', + ], + ]; + } + } + + /** + * Get Steam user profile information. + * + * @param string $steamId Steam ID or vanity URL + * + * @return array{ + * steam_id: string, + * person_name: string, + * real_name: string, + * profile_url: string, + * avatar: string, + * avatar_medium: string, + * avatar_full: string, + * person_state: int, + * community_visibility_state: int, + * profile_state: int, + * last_logoff: int, + * comment_permission: int, + * country_code: string, + * state_code: string, + * city_id: int, + * }|string + */ + public function getUserProfile(string $steamId): array|string + { + try { + if (!$this->apiKey) { + return 'Steam API key required for user profile access'; + } + + $response = $this->httpClient->request('GET', 'https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/', [ + 'query' => [ + 'key' => $this->apiKey, + 'steamids' => $steamId, + ], + ]); + + $data = $response->toArray(); + + if (!isset($data['response']['players'][0])) { + return 'User not found or profile not public'; + } + + $player = $data['response']['players'][0]; + + return [ + 'steam_id' => $player['steamid'], + 'person_name' => $player['personaname'], + 'real_name' => $player['realname'] ?? '', + 'profile_url' => $player['profileurl'], + 'avatar' => $player['avatar'], + 'avatar_medium' => $player['avatarmedium'], + 'avatar_full' => $player['avatarfull'], + 'person_state' => $player['personastate'], + 'community_visibility_state' => $player['communityvisibilitystate'], + 'profile_state' => $player['profilestate'], + 'last_logoff' => $player['lastlogoff'], + 'comment_permission' => $player['commentpermission'], + 'country_code' => $player['loccountrycode'] ?? '', + 'state_code' => $player['locstatecode'] ?? '', + 'city_id' => $player['loccityid'] ?? 0, + ]; + } catch (\Exception $e) { + return 'Error getting user profile: '.$e->getMessage(); + } + } + + /** + * Get detailed information about a Steam game. + * + * @param int $appId Steam App ID + * + * @return array{ + * app_id: int, + * name: string, + * type: string, + * description: string, + * short_description: string, + * header_image: string, + * capsule_image: string, + * capsule_image_v5: string, + * website: string, + * pc_requirements: array{minimum: string, recommended: string}, + * mac_requirements: array{minimum: string, recommended: string}, + * linux_requirements: array{minimum: string, recommended: string}, + * developers: array, + * publishers: array, + * price_overview: array, + * platforms: array{windows: bool, mac: bool, linux: bool}, + * categories: array, + * genres: array, + * screenshots: array, + * movies: array}>, + * release_date: array{coming_soon: bool, date: string}, + * background: string, + * background_raw: string, + * }|string + */ + public function getGameDetails(int $appId): array|string + { + try { + $response = $this->httpClient->request('GET', 'https://store.steampowered.com/api/appdetails', [ + 'query' => [ + 'appids' => $appId, + 'l' => 'english', + ], + ]); + + $data = $response->toArray(); + + if (!isset($data[$appId]['data'])) { + return 'Game not found or not available'; + } + + $gameData = $data[$appId]['data']; + + return [ + 'app_id' => $appId, + 'name' => $gameData['name'], + 'type' => $gameData['type'], + 'description' => $gameData['detailed_description'] ?? '', + 'short_description' => $gameData['short_description'] ?? '', + 'header_image' => $gameData['header_image'] ?? '', + 'capsule_image' => $gameData['capsule_image'] ?? '', + 'capsule_image_v5' => $gameData['capsule_imagev5'] ?? '', + 'website' => $gameData['website'] ?? '', + 'pc_requirements' => [ + 'minimum' => $gameData['pc_requirements']['minimum'] ?? '', + 'recommended' => $gameData['pc_requirements']['recommended'] ?? '', + ], + 'mac_requirements' => [ + 'minimum' => $gameData['mac_requirements']['minimum'] ?? '', + 'recommended' => $gameData['mac_requirements']['recommended'] ?? '', + ], + 'linux_requirements' => [ + 'minimum' => $gameData['linux_requirements']['minimum'] ?? '', + 'recommended' => $gameData['linux_requirements']['recommended'] ?? '', + ], + 'developers' => $gameData['developers'] ?? [], + 'publishers' => $gameData['publishers'] ?? [], + 'price_overview' => $gameData['price_overview'] ?? [], + 'platforms' => $gameData['platforms'] ?? ['windows' => false, 'mac' => false, 'linux' => false], + 'categories' => $this->formatCategories($gameData['categories'] ?? []), + 'genres' => $this->formatGenres($gameData['genres'] ?? []), + 'screenshots' => $gameData['screenshots'] ?? [], + 'movies' => $gameData['movies'] ?? [], + 'release_date' => $gameData['release_date'] ?? ['coming_soon' => false, 'date' => ''], + 'background' => $gameData['background'] ?? '', + 'background_raw' => $gameData['background_raw'] ?? '', + ]; + } catch (\Exception $e) { + return 'Error getting game details: '.$e->getMessage(); + } + } + + /** + * Get user's game library. + * + * @param string $steamId Steam ID + * + * @return array|string + */ + public function getUserGames(string $steamId): array|string + { + try { + if (!$this->apiKey) { + return 'Steam API key required for user game library access'; + } + + $response = $this->httpClient->request('GET', 'https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/', [ + 'query' => [ + 'key' => $this->apiKey, + 'steamid' => $steamId, + 'include_appinfo' => true, + 'include_played_free_games' => true, + ], + ]); + + $data = $response->toArray(); + + if (!isset($data['response']['games'])) { + return 'No games found or library not public'; + } + + $results = []; + foreach ($data['response']['games'] as $game) { + $results[] = [ + 'app_id' => $game['appid'], + 'name' => $game['name'], + 'playtime_forever' => $game['playtime_forever'] ?? 0, + 'playtime_2weeks' => $game['playtime_2weeks'] ?? 0, + 'playtime_windows_forever' => $game['playtime_windows_forever'] ?? 0, + 'playtime_mac_forever' => $game['playtime_mac_forever'] ?? 0, + 'playtime_linux_forever' => $game['playtime_linux_forever'] ?? 0, + 'rtime_last_played' => $game['rtime_last_played'] ?? 0, + 'playtime_disconnected' => $game['playtime_disconnected'] ?? 0, + ]; + } + + return $results; + } catch (\Exception $e) { + return 'Error getting user games: '.$e->getMessage(); + } + } + + /** + * Format categories data. + * + * @param array> $categories + * + * @return array + */ + private function formatCategories(array $categories): array + { + $formatted = []; + foreach ($categories as $category) { + $formatted[] = [ + 'id' => $category['id'], + 'description' => $category['description'], + ]; + } + + return $formatted; + } + + /** + * Format genres data. + * + * @param array> $genres + * + * @return array + */ + private function formatGenres(array $genres): array + { + $formatted = []; + foreach ($genres as $genre) { + $formatted[] = [ + 'id' => $genre['id'], + 'description' => $genre['description'], + ]; + } + + return $formatted; + } +} diff --git a/src/agent/src/Toolbox/Tool/SteamshipImage.php b/src/agent/src/Toolbox/Tool/SteamshipImage.php new file mode 100644 index 000000000..41b009a1f --- /dev/null +++ b/src/agent/src/Toolbox/Tool/SteamshipImage.php @@ -0,0 +1,924 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('steamship_image_generate', 'Tool that generates images using Steamship Image Generation')] +#[AsTool('steamship_image_upscale', 'Tool that upscales images', method: 'upscaleImage')] +#[AsTool('steamship_image_edit', 'Tool that edits images', method: 'editImage')] +#[AsTool('steamship_image_variate', 'Tool that creates image variations', method: 'variateImage')] +#[AsTool('steamship_image_remove_background', 'Tool that removes image backgrounds', method: 'removeBackground')] +#[AsTool('steamship_image_style_transfer', 'Tool that applies style transfer', method: 'styleTransfer')] +#[AsTool('steamship_image_inpaint', 'Tool that performs image inpainting', method: 'inpaintImage')] +#[AsTool('steamship_image_outpaint', 'Tool that performs image outpainting', method: 'outpaintImage')] +final readonly class SteamshipImage +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $workspaceId, + private string $baseUrl = 'https://api.steamship.com', + private array $options = [], + ) { + } + + /** + * Generate images using Steamship Image Generation. + * + * @param string $prompt Text prompt for image generation + * @param int $width Image width + * @param int $height Image height + * @param string $model Model to use + * @param array $parameters Generation parameters + * @param array $options Generation options + * + * @return array{ + * success: bool, + * image_generation: array{ + * prompt: string, + * width: int, + * height: int, + * model: string, + * parameters: array, + * generated_images: array, + * generation_settings: array{ + * batch_size: int, + * quality: string, + * style: string, + * artist: string, + * }, + * usage_stats: array{ + * tokens_used: int, + * credits_consumed: float, + * estimated_cost: float, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $prompt, + int $width = 512, + int $height = 512, + string $model = 'stable-diffusion-xl', + array $parameters = [], + array $options = [], + ): array { + try { + $requestData = [ + 'prompt' => $prompt, + 'width' => $width, + 'height' => $height, + 'model' => $model, + 'parameters' => array_merge([ + 'num_inference_steps' => $parameters['steps'] ?? 20, + 'guidance_scale' => $parameters['guidance_scale'] ?? 7.5, + 'seed' => $parameters['seed'] ?? random_int(0, 2147483647), + 'negative_prompt' => $parameters['negative_prompt'] ?? '', + 'scheduler' => $parameters['scheduler'] ?? 'DPMSolverMultistepScheduler', + ], $parameters), + 'options' => array_merge([ + 'batch_size' => $options['batch_size'] ?? 1, + 'quality' => $options['quality'] ?? 'standard', + 'style' => $options['style'] ?? 'realistic', + 'artist' => $options['artist'] ?? '', + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/workspace/{$this->workspaceId}/image/generate", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $images = $responseData['images'] ?? []; + + return [ + 'success' => !empty($images), + 'image_generation' => [ + 'prompt' => $prompt, + 'width' => $width, + 'height' => $height, + 'model' => $model, + 'parameters' => $parameters, + 'generated_images' => array_map(fn ($image, $index) => [ + 'image_url' => $image['url'] ?? '', + 'image_id' => $image['id'] ?? "generated_{$index}", + 'seed' => $image['seed'] ?? $parameters['seed'], + 'steps' => $image['steps'] ?? $parameters['steps'] ?? 20, + 'guidance_scale' => $image['guidance_scale'] ?? $parameters['guidance_scale'] ?? 7.5, + 'negative_prompt' => $image['negative_prompt'] ?? $parameters['negative_prompt'] ?? '', + 'generation_time' => $image['generation_time'] ?? 0.0, + ], $images, array_keys($images)), + 'generation_settings' => [ + 'batch_size' => $options['batch_size'] ?? 1, + 'quality' => $options['quality'] ?? 'standard', + 'style' => $options['style'] ?? 'realistic', + 'artist' => $options['artist'] ?? '', + ], + 'usage_stats' => [ + 'tokens_used' => $responseData['usage']['tokens'] ?? 0, + 'credits_consumed' => $responseData['usage']['credits'] ?? 0.0, + 'estimated_cost' => $responseData['usage']['cost'] ?? 0.0, + ], + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'image_generation' => [ + 'prompt' => $prompt, + 'width' => $width, + 'height' => $height, + 'model' => $model, + 'parameters' => $parameters, + 'generated_images' => [], + 'generation_settings' => [ + 'batch_size' => 1, + 'quality' => 'standard', + 'style' => 'realistic', + 'artist' => '', + ], + 'usage_stats' => [ + 'tokens_used' => 0, + 'credits_consumed' => 0.0, + 'estimated_cost' => 0.0, + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Upscale images. + * + * @param string $imageUrl URL of image to upscale + * @param int $scaleFactor Scale factor (2x, 4x, 8x) + * @param string $upscaleModel Upscaling model to use + * @param array $options Upscaling options + * + * @return array{ + * success: bool, + * image_upscaling: array{ + * image_url: string, + * scale_factor: int, + * upscale_model: string, + * upscaled_image: array{ + * image_url: string, + * original_width: int, + * original_height: int, + * upscaled_width: int, + * upscaled_height: int, + * upscaling_time: float, + * quality_improvement: float, + * }, + * processing_options: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function upscaleImage( + string $imageUrl, + int $scaleFactor = 4, + string $upscaleModel = 'real-esrgan', + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'scale_factor' => $scaleFactor, + 'upscale_model' => $upscaleModel, + 'options' => array_merge([ + 'preserve_details' => $options['preserve_details'] ?? true, + 'enhance_faces' => $options['enhance_faces'] ?? true, + 'remove_noise' => $options['remove_noise'] ?? true, + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/workspace/{$this->workspaceId}/image/upscale", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $upscaledImage = $responseData['upscaled_image'] ?? []; + + return [ + 'success' => !empty($upscaledImage['url']), + 'image_upscaling' => [ + 'image_url' => $imageUrl, + 'scale_factor' => $scaleFactor, + 'upscale_model' => $upscaleModel, + 'upscaled_image' => [ + 'image_url' => $upscaledImage['url'] ?? '', + 'original_width' => $upscaledImage['original_width'] ?? 0, + 'original_height' => $upscaledImage['original_height'] ?? 0, + 'upscaled_width' => $upscaledImage['upscaled_width'] ?? 0, + 'upscaled_height' => $upscaledImage['upscaled_height'] ?? 0, + 'upscaling_time' => $upscaledImage['processing_time'] ?? 0.0, + 'quality_improvement' => $upscaledImage['quality_improvement'] ?? 0.0, + ], + 'processing_options' => $options, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'image_upscaling' => [ + 'image_url' => $imageUrl, + 'scale_factor' => $scaleFactor, + 'upscale_model' => $upscaleModel, + 'upscaled_image' => [ + 'image_url' => '', + 'original_width' => 0, + 'original_height' => 0, + 'upscaled_width' => 0, + 'upscaled_height' => 0, + 'upscaling_time' => 0.0, + 'quality_improvement' => 0.0, + ], + 'processing_options' => $options, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Edit images. + * + * @param string $imageUrl URL of image to edit + * @param string $editPrompt Text prompt describing the edit + * @param string $editType Type of edit (replace, add, modify) + * @param array $editParams Edit parameters + * @param array $options Edit options + * + * @return array{ + * success: bool, + * image_editing: array{ + * image_url: string, + * edit_prompt: string, + * edit_type: string, + * edit_params: array, + * edited_image: array{ + * image_url: string, + * edit_mask: string, + * confidence_score: float, + * edit_region: array{ + * x: int, + * y: int, + * width: int, + * height: int, + * }, + * before_after_comparison: array{ + * similarity_score: float, + * changes_detected: array, + * }, + * }, + * editing_options: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function editImage( + string $imageUrl, + string $editPrompt, + string $editType = 'modify', + array $editParams = [], + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'edit_prompt' => $editPrompt, + 'edit_type' => $editType, + 'edit_params' => array_merge([ + 'strength' => $editParams['strength'] ?? 0.8, + 'guidance_scale' => $editParams['guidance_scale'] ?? 7.5, + 'num_inference_steps' => $editParams['steps'] ?? 20, + 'seed' => $editParams['seed'] ?? random_int(0, 2147483647), + ], $editParams), + 'options' => array_merge([ + 'preserve_original' => $options['preserve_original'] ?? true, + 'auto_mask' => $options['auto_mask'] ?? true, + 'blend_edges' => $options['blend_edges'] ?? true, + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/workspace/{$this->workspaceId}/image/edit", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $editedImage = $responseData['edited_image'] ?? []; + + return [ + 'success' => !empty($editedImage['url']), + 'image_editing' => [ + 'image_url' => $imageUrl, + 'edit_prompt' => $editPrompt, + 'edit_type' => $editType, + 'edit_params' => $editParams, + 'edited_image' => [ + 'image_url' => $editedImage['url'] ?? '', + 'edit_mask' => $editedImage['mask_url'] ?? '', + 'confidence_score' => $editedImage['confidence'] ?? 0.0, + 'edit_region' => [ + 'x' => $editedImage['edit_region']['x'] ?? 0, + 'y' => $editedImage['edit_region']['y'] ?? 0, + 'width' => $editedImage['edit_region']['width'] ?? 0, + 'height' => $editedImage['edit_region']['height'] ?? 0, + ], + 'before_after_comparison' => [ + 'similarity_score' => $editedImage['similarity_score'] ?? 0.0, + 'changes_detected' => $editedImage['changes'] ?? [], + ], + ], + 'editing_options' => $options, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'image_editing' => [ + 'image_url' => $imageUrl, + 'edit_prompt' => $editPrompt, + 'edit_type' => $editType, + 'edit_params' => $editParams, + 'edited_image' => [ + 'image_url' => '', + 'edit_mask' => '', + 'confidence_score' => 0.0, + 'edit_region' => [ + 'x' => 0, + 'y' => 0, + 'width' => 0, + 'height' => 0, + ], + 'before_after_comparison' => [ + 'similarity_score' => 0.0, + 'changes_detected' => [], + ], + ], + 'editing_options' => $options, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create image variations. + * + * @param string $imageUrl URL of image to vary + * @param int $numVariations Number of variations to create + * @param float $variationStrength Strength of variation + * @param array $options Variation options + * + * @return array{ + * success: bool, + * image_variations: array{ + * image_url: string, + * num_variations: int, + * variation_strength: float, + * variations: array, + * variation_options: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function variateImage( + string $imageUrl, + int $numVariations = 4, + float $variationStrength = 0.7, + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'num_variations' => min($numVariations, 8), + 'variation_strength' => $variationStrength, + 'options' => array_merge([ + 'preserve_style' => $options['preserve_style'] ?? true, + 'preserve_composition' => $options['preserve_composition'] ?? true, + 'random_seed' => $options['random_seed'] ?? true, + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/workspace/{$this->workspaceId}/image/variate", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $variations = $responseData['variations'] ?? []; + + return [ + 'success' => !empty($variations), + 'image_variations' => [ + 'image_url' => $imageUrl, + 'num_variations' => $numVariations, + 'variation_strength' => $variationStrength, + 'variations' => array_map(fn ($variation, $index) => [ + 'image_url' => $variation['url'] ?? '', + 'variation_id' => $variation['id'] ?? "variation_{$index}", + 'similarity_score' => $variation['similarity'] ?? 0.0, + 'variation_type' => $variation['type'] ?? 'style', + 'seed' => $variation['seed'] ?? random_int(0, 2147483647), + ], $variations, array_keys($variations)), + 'variation_options' => $options, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'image_variations' => [ + 'image_url' => $imageUrl, + 'num_variations' => $numVariations, + 'variation_strength' => $variationStrength, + 'variations' => [], + 'variation_options' => $options, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Remove image backgrounds. + * + * @param string $imageUrl URL of image to process + * @param string $backgroundType Type of background (transparent, white, custom) + * @param string $customBackground Custom background color/URL + * @param array $options Background removal options + * + * @return array{ + * success: bool, + * background_removal: array{ + * image_url: string, + * background_type: string, + * custom_background: string, + * processed_image: array{ + * image_url: string, + * mask_url: string, + * foreground_url: string, + * background_removal_confidence: float, + * edge_quality: string, + * }, + * removal_options: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function removeBackground( + string $imageUrl, + string $backgroundType = 'transparent', + string $customBackground = '', + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'background_type' => $backgroundType, + 'custom_background' => $customBackground, + 'options' => array_merge([ + 'edge_smoothing' => $options['edge_smoothing'] ?? true, + 'hair_detection' => $options['hair_detection'] ?? true, + 'fine_details' => $options['fine_details'] ?? true, + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/workspace/{$this->workspaceId}/image/remove-background", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $processedImage = $responseData['processed_image'] ?? []; + + return [ + 'success' => !empty($processedImage['url']), + 'background_removal' => [ + 'image_url' => $imageUrl, + 'background_type' => $backgroundType, + 'custom_background' => $customBackground, + 'processed_image' => [ + 'image_url' => $processedImage['url'] ?? '', + 'mask_url' => $processedImage['mask_url'] ?? '', + 'foreground_url' => $processedImage['foreground_url'] ?? '', + 'background_removal_confidence' => $processedImage['confidence'] ?? 0.0, + 'edge_quality' => $processedImage['edge_quality'] ?? 'high', + ], + 'removal_options' => $options, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'background_removal' => [ + 'image_url' => $imageUrl, + 'background_type' => $backgroundType, + 'custom_background' => $customBackground, + 'processed_image' => [ + 'image_url' => '', + 'mask_url' => '', + 'foreground_url' => '', + 'background_removal_confidence' => 0.0, + 'edge_quality' => '', + ], + 'removal_options' => $options, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Apply style transfer. + * + * @param string $contentImage URL of content image + * @param string $styleImage URL of style image + * @param float $styleStrength Strength of style transfer + * @param array $options Style transfer options + * + * @return array{ + * success: bool, + * style_transfer: array{ + * content_image: string, + * style_image: string, + * style_strength: float, + * styled_image: array{ + * image_url: string, + * style_similarity: float, + * content_preservation: float, + * transfer_quality: string, + * }, + * transfer_options: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function styleTransfer( + string $contentImage, + string $styleImage, + float $styleStrength = 0.8, + array $options = [], + ): array { + try { + $requestData = [ + 'content_image' => $contentImage, + 'style_image' => $styleImage, + 'style_strength' => $styleStrength, + 'options' => array_merge([ + 'preserve_content' => $options['preserve_content'] ?? true, + 'preserve_colors' => $options['preserve_colors'] ?? false, + 'blend_mode' => $options['blend_mode'] ?? 'normal', + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/workspace/{$this->workspaceId}/image/style-transfer", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $styledImage = $responseData['styled_image'] ?? []; + + return [ + 'success' => !empty($styledImage['url']), + 'style_transfer' => [ + 'content_image' => $contentImage, + 'style_image' => $styleImage, + 'style_strength' => $styleStrength, + 'styled_image' => [ + 'image_url' => $styledImage['url'] ?? '', + 'style_similarity' => $styledImage['style_similarity'] ?? 0.0, + 'content_preservation' => $styledImage['content_preservation'] ?? 0.0, + 'transfer_quality' => $styledImage['quality'] ?? 'high', + ], + 'transfer_options' => $options, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'style_transfer' => [ + 'content_image' => $contentImage, + 'style_image' => $styleImage, + 'style_strength' => $styleStrength, + 'styled_image' => [ + 'image_url' => '', + 'style_similarity' => 0.0, + 'content_preservation' => 0.0, + 'transfer_quality' => '', + ], + 'transfer_options' => $options, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Perform image inpainting. + * + * @param string $imageUrl URL of image to inpaint + * @param string $maskUrl URL of mask image + * @param string $inpaintPrompt Text prompt for inpainting + * @param array $options Inpainting options + * + * @return array{ + * success: bool, + * image_inpainting: array{ + * image_url: string, + * mask_url: string, + * inpaint_prompt: string, + * inpainted_image: array{ + * image_url: string, + * inpaint_confidence: float, + * seamless_blend: bool, + * inpaint_region: array{ + * x: int, + * y: int, + * width: int, + * height: int, + * }, + * }, + * inpainting_options: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function inpaintImage( + string $imageUrl, + string $maskUrl, + string $inpaintPrompt, + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'mask_url' => $maskUrl, + 'inpaint_prompt' => $inpaintPrompt, + 'options' => array_merge([ + 'strength' => $options['strength'] ?? 0.8, + 'guidance_scale' => $options['guidance_scale'] ?? 7.5, + 'num_inference_steps' => $options['steps'] ?? 20, + 'seed' => $options['seed'] ?? random_int(0, 2147483647), + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/workspace/{$this->workspaceId}/image/inpaint", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $inpaintedImage = $responseData['inpainted_image'] ?? []; + + return [ + 'success' => !empty($inpaintedImage['url']), + 'image_inpainting' => [ + 'image_url' => $imageUrl, + 'mask_url' => $maskUrl, + 'inpaint_prompt' => $inpaintPrompt, + 'inpainted_image' => [ + 'image_url' => $inpaintedImage['url'] ?? '', + 'inpaint_confidence' => $inpaintedImage['confidence'] ?? 0.0, + 'seamless_blend' => $inpaintedImage['seamless'] ?? true, + 'inpaint_region' => [ + 'x' => $inpaintedImage['region']['x'] ?? 0, + 'y' => $inpaintedImage['region']['y'] ?? 0, + 'width' => $inpaintedImage['region']['width'] ?? 0, + 'height' => $inpaintedImage['region']['height'] ?? 0, + ], + ], + 'inpainting_options' => $options, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'image_inpainting' => [ + 'image_url' => $imageUrl, + 'mask_url' => $maskUrl, + 'inpaint_prompt' => $inpaintPrompt, + 'inpainted_image' => [ + 'image_url' => '', + 'inpaint_confidence' => 0.0, + 'seamless_blend' => false, + 'inpaint_region' => [ + 'x' => 0, + 'y' => 0, + 'width' => 0, + 'height' => 0, + ], + ], + 'inpainting_options' => $options, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Perform image outpainting. + * + * @param string $imageUrl URL of image to outpaint + * @param string $outpaintPrompt Text prompt for outpainting + * @param array $outpaintParams Outpainting parameters + * @param array $options Outpainting options + * + * @return array{ + * success: bool, + * image_outpainting: array{ + * image_url: string, + * outpaint_prompt: string, + * outpaint_params: array, + * outpainted_image: array{ + * image_url: string, + * original_dimensions: array{ + * width: int, + * height: int, + * }, + * outpainted_dimensions: array{ + * width: int, + * height: int, + * }, + * expansion_ratio: float, + * seamless_continuation: bool, + * }, + * outpainting_options: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function outpaintImage( + string $imageUrl, + string $outpaintPrompt, + array $outpaintParams = [], + array $options = [], + ): array { + try { + $requestData = [ + 'image_url' => $imageUrl, + 'outpaint_prompt' => $outpaintPrompt, + 'outpaint_params' => array_merge([ + 'expansion_ratio' => $outpaintParams['expansion_ratio'] ?? 1.5, + 'direction' => $outpaintParams['direction'] ?? 'all', + 'strength' => $outpaintParams['strength'] ?? 0.8, + 'guidance_scale' => $outpaintParams['guidance_scale'] ?? 7.5, + ], $outpaintParams), + 'options' => array_merge([ + 'preserve_original' => $options['preserve_original'] ?? true, + 'seamless_blend' => $options['seamless_blend'] ?? true, + 'auto_crop' => $options['auto_crop'] ?? false, + ], $options), + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/v1/workspace/{$this->workspaceId}/image/outpaint", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $responseData = $response->toArray(); + $outpaintedImage = $responseData['outpainted_image'] ?? []; + + return [ + 'success' => !empty($outpaintedImage['url']), + 'image_outpainting' => [ + 'image_url' => $imageUrl, + 'outpaint_prompt' => $outpaintPrompt, + 'outpaint_params' => $outpaintParams, + 'outpainted_image' => [ + 'image_url' => $outpaintedImage['url'] ?? '', + 'original_dimensions' => [ + 'width' => $outpaintedImage['original_width'] ?? 0, + 'height' => $outpaintedImage['original_height'] ?? 0, + ], + 'outpainted_dimensions' => [ + 'width' => $outpaintedImage['outpainted_width'] ?? 0, + 'height' => $outpaintedImage['outpainted_height'] ?? 0, + ], + 'expansion_ratio' => $outpaintedImage['expansion_ratio'] ?? 1.5, + 'seamless_continuation' => $outpaintedImage['seamless'] ?? true, + ], + 'outpainting_options' => $options, + ], + 'processingTime' => $responseData['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'image_outpainting' => [ + 'image_url' => $imageUrl, + 'outpaint_prompt' => $outpaintPrompt, + 'outpaint_params' => $outpaintParams, + 'outpainted_image' => [ + 'image_url' => '', + 'original_dimensions' => [ + 'width' => 0, + 'height' => 0, + ], + 'outpainted_dimensions' => [ + 'width' => 0, + 'height' => 0, + ], + 'expansion_ratio' => 0.0, + 'seamless_continuation' => false, + ], + 'outpainting_options' => $options, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Stripe.php b/src/agent/src/Toolbox/Tool/Stripe.php new file mode 100644 index 000000000..9d4a583a9 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Stripe.php @@ -0,0 +1,578 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('stripe_create_payment_intent', 'Tool that creates Stripe payment intents')] +#[AsTool('stripe_create_customer', 'Tool that creates Stripe customers', method: 'createCustomer')] +#[AsTool('stripe_create_subscription', 'Tool that creates Stripe subscriptions', method: 'createSubscription')] +#[AsTool('stripe_list_payments', 'Tool that lists Stripe payments', method: 'listPayments')] +#[AsTool('stripe_refund_payment', 'Tool that refunds Stripe payments', method: 'refundPayment')] +#[AsTool('stripe_create_product', 'Tool that creates Stripe products', method: 'createProduct')] +#[AsTool('stripe_create_price', 'Tool that creates Stripe prices', method: 'createPrice')] +final readonly class Stripe +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $secretKey, + private array $options = [], + ) { + } + + /** + * Create a Stripe payment intent. + * + * @param int $amount Amount in cents + * @param string $currency Currency code (e.g., 'usd', 'eur') + * @param string $customerId Optional customer ID + * @param array $metadata Optional metadata + * @param string $description Optional description + * + * @return array{ + * id: string, + * amount: int, + * currency: string, + * status: string, + * client_secret: string, + * customer: string|null, + * description: string|null, + * metadata: array, + * created: int, + * }|string + */ + public function __invoke( + int $amount, + string $currency = 'usd', + string $customerId = '', + array $metadata = [], + string $description = '', + ): array|string { + try { + $payload = [ + 'amount' => $amount, + 'currency' => strtolower($currency), + ]; + + if ($customerId) { + $payload['customer'] = $customerId; + } + + if (!empty($metadata)) { + $payload['metadata'] = $metadata; + } + + if ($description) { + $payload['description'] = $description; + } + + $response = $this->httpClient->request('POST', 'https://api.stripe.com/v1/payment_intents', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->secretKey, + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => http_build_query($payload), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating payment intent: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'amount' => $data['amount'], + 'currency' => $data['currency'], + 'status' => $data['status'], + 'client_secret' => $data['client_secret'], + 'customer' => $data['customer'] ?? null, + 'description' => $data['description'] ?? null, + 'metadata' => $data['metadata'] ?? [], + 'created' => $data['created'], + ]; + } catch (\Exception $e) { + return 'Error creating payment intent: '.$e->getMessage(); + } + } + + /** + * Create a Stripe customer. + * + * @param string $email Customer email + * @param string $name Customer name + * @param string $description Optional description + * @param array $metadata Optional metadata + * + * @return array{ + * id: string, + * email: string, + * name: string|null, + * description: string|null, + * metadata: array, + * created: int, + * livemode: bool, + * }|string + */ + public function createCustomer( + string $email, + string $name = '', + string $description = '', + array $metadata = [], + ): array|string { + try { + $payload = [ + 'email' => $email, + ]; + + if ($name) { + $payload['name'] = $name; + } + + if ($description) { + $payload['description'] = $description; + } + + if (!empty($metadata)) { + $payload['metadata'] = $metadata; + } + + $response = $this->httpClient->request('POST', 'https://api.stripe.com/v1/customers', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->secretKey, + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => http_build_query($payload), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating customer: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'email' => $data['email'], + 'name' => $data['name'] ?? null, + 'description' => $data['description'] ?? null, + 'metadata' => $data['metadata'] ?? [], + 'created' => $data['created'], + 'livemode' => $data['livemode'], + ]; + } catch (\Exception $e) { + return 'Error creating customer: '.$e->getMessage(); + } + } + + /** + * Create a Stripe subscription. + * + * @param string $customerId Customer ID + * @param array $priceIds Array of price IDs + * @param string $paymentBehavior Payment behavior (default_incomplete, allow_incomplete, error_if_incomplete) + * @param array $metadata Optional metadata + * + * @return array{ + * id: string, + * status: string, + * customer: string, + * current_period_start: int, + * current_period_end: int, + * created: int, + * metadata: array, + * latest_invoice: string|null, + * }|string + */ + public function createSubscription( + string $customerId, + array $priceIds, + string $paymentBehavior = 'default_incomplete', + array $metadata = [], + ): array|string { + try { + $payload = [ + 'customer' => $customerId, + 'payment_behavior' => $paymentBehavior, + 'items' => array_map(fn ($priceId) => ['price' => $priceId], $priceIds), + ]; + + if (!empty($metadata)) { + $payload['metadata'] = $metadata; + } + + $response = $this->httpClient->request('POST', 'https://api.stripe.com/v1/subscriptions', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->secretKey, + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => http_build_query($payload), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating subscription: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'status' => $data['status'], + 'customer' => $data['customer'], + 'current_period_start' => $data['current_period_start'], + 'current_period_end' => $data['current_period_end'], + 'created' => $data['created'], + 'metadata' => $data['metadata'] ?? [], + 'latest_invoice' => $data['latest_invoice'] ?? null, + ]; + } catch (\Exception $e) { + return 'Error creating subscription: '.$e->getMessage(); + } + } + + /** + * List Stripe payments. + * + * @param int $limit Maximum number of payments to retrieve + * @param string $customer Optional customer ID filter + * @param string $status Optional status filter + * + * @return array, + * created: int, + * payment_method: string|null, + * }> + */ + public function listPayments( + int $limit = 10, + string $customer = '', + string $status = '', + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), + ]; + + if ($customer) { + $params['customer'] = $customer; + } + + if ($status) { + $params['status'] = $status; + } + + $response = $this->httpClient->request('GET', 'https://api.stripe.com/v1/payment_intents', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->secretKey, + ], + 'query' => $params, + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return []; + } + + $payments = []; + foreach ($data['data'] as $payment) { + $payments[] = [ + 'id' => $payment['id'], + 'amount' => $payment['amount'], + 'currency' => $payment['currency'], + 'status' => $payment['status'], + 'customer' => $payment['customer'] ?? null, + 'description' => $payment['description'] ?? null, + 'metadata' => $payment['metadata'] ?? [], + 'created' => $payment['created'], + 'payment_method' => $payment['payment_method'] ?? null, + ]; + } + + return $payments; + } catch (\Exception $e) { + return []; + } + } + + /** + * Refund a Stripe payment. + * + * @param string $paymentIntentId Payment intent ID to refund + * @param int $amount Refund amount in cents (optional, full refund if not specified) + * @param string $reason Refund reason (duplicate, fraudulent, requested_by_customer) + * @param array $metadata Optional metadata + * + * @return array{ + * id: string, + * amount: int, + * currency: string, + * status: string, + * payment_intent: string, + * reason: string|null, + * metadata: array, + * created: int, + * }|string + */ + public function refundPayment( + string $paymentIntentId, + int $amount = 0, + string $reason = 'requested_by_customer', + array $metadata = [], + ): array|string { + try { + $payload = [ + 'payment_intent' => $paymentIntentId, + 'reason' => $reason, + ]; + + if ($amount > 0) { + $payload['amount'] = $amount; + } + + if (!empty($metadata)) { + $payload['metadata'] = $metadata; + } + + $response = $this->httpClient->request('POST', 'https://api.stripe.com/v1/refunds', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->secretKey, + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => http_build_query($payload), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error refunding payment: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'amount' => $data['amount'], + 'currency' => $data['currency'], + 'status' => $data['status'], + 'payment_intent' => $data['payment_intent'], + 'reason' => $data['reason'] ?? null, + 'metadata' => $data['metadata'] ?? [], + 'created' => $data['created'], + ]; + } catch (\Exception $e) { + return 'Error refunding payment: '.$e->getMessage(); + } + } + + /** + * Create a Stripe product. + * + * @param string $name Product name + * @param string $description Product description + * @param array $metadata Optional metadata + * @param bool $active Whether the product is active + * + * @return array{ + * id: string, + * name: string, + * description: string|null, + * active: bool, + * metadata: array, + * created: int, + * updated: int, + * }|string + */ + public function createProduct( + string $name, + string $description = '', + array $metadata = [], + bool $active = true, + ): array|string { + try { + $payload = [ + 'name' => $name, + 'active' => $active, + ]; + + if ($description) { + $payload['description'] = $description; + } + + if (!empty($metadata)) { + $payload['metadata'] = $metadata; + } + + $response = $this->httpClient->request('POST', 'https://api.stripe.com/v1/products', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->secretKey, + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => http_build_query($payload), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating product: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'active' => $data['active'], + 'metadata' => $data['metadata'] ?? [], + 'created' => $data['created'], + 'updated' => $data['updated'], + ]; + } catch (\Exception $e) { + return 'Error creating product: '.$e->getMessage(); + } + } + + /** + * Create a Stripe price. + * + * @param string $productId Product ID + * @param int $unitAmount Unit amount in cents + * @param string $currency Currency code + * @param string $recurring Recurring interval (day, week, month, year) or empty for one-time + * @param array $metadata Optional metadata + * + * @return array{ + * id: string, + * product: string, + * unit_amount: int, + * currency: string, + * recurring: array{interval: string, interval_count: int}|null, + * active: bool, + * metadata: array, + * created: int, + * type: string, + * }|string + */ + public function createPrice( + string $productId, + int $unitAmount, + string $currency = 'usd', + string $recurring = '', + array $metadata = [], + ): array|string { + try { + $payload = [ + 'product' => $productId, + 'unit_amount' => $unitAmount, + 'currency' => strtolower($currency), + ]; + + if ($recurring) { + $payload['recurring'] = [ + 'interval' => $recurring, + ]; + } + + if (!empty($metadata)) { + $payload['metadata'] = $metadata; + } + + $response = $this->httpClient->request('POST', 'https://api.stripe.com/v1/prices', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->secretKey, + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => http_build_query($payload), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating price: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'product' => $data['product'], + 'unit_amount' => $data['unit_amount'], + 'currency' => $data['currency'], + 'recurring' => $data['recurring'] ?? null, + 'active' => $data['active'], + 'metadata' => $data['metadata'] ?? [], + 'created' => $data['created'], + 'type' => $data['type'], + ]; + } catch (\Exception $e) { + return 'Error creating price: '.$e->getMessage(); + } + } + + /** + * Get Stripe customer information. + * + * @param string $customerId Customer ID + * + * @return array{ + * id: string, + * email: string, + * name: string|null, + * description: string|null, + * balance: int, + * currency: string|null, + * created: int, + * livemode: bool, + * metadata: array, + * subscriptions: array{data: array>}, + * }|string + */ + public function getCustomer(string $customerId): array|string + { + try { + $response = $this->httpClient->request('GET', "https://api.stripe.com/v1/customers/{$customerId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->secretKey, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting customer: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'email' => $data['email'], + 'name' => $data['name'] ?? null, + 'description' => $data['description'] ?? null, + 'balance' => $data['balance'], + 'currency' => $data['currency'] ?? null, + 'created' => $data['created'], + 'livemode' => $data['livemode'], + 'metadata' => $data['metadata'] ?? [], + 'subscriptions' => $data['subscriptions'] ?? ['data' => []], + ]; + } catch (\Exception $e) { + return 'Error getting customer: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Tavily.php b/src/agent/src/Toolbox/Tool/Tavily.php index 36405b42e..4a3ac6fa5 100644 --- a/src/agent/src/Toolbox/Tool/Tavily.php +++ b/src/agent/src/Toolbox/Tool/Tavily.php @@ -17,10 +17,13 @@ /** * Tool integration of tavily.com. * - * @author Christopher Hertel + * @author Mathieu Ledru */ #[AsTool('tavily_search', description: 'search for information on the internet', method: 'search')] #[AsTool('tavily_extract', description: 'fetch content from websites', method: 'extract')] +#[AsTool('tavily_search_news', description: 'search for news using Tavily', method: 'searchNews')] +#[AsTool('tavily_search_documents', description: 'search documents using Tavily', method: 'searchDocuments')] +#[AsTool('tavily_get_answer', description: 'get direct answers using Tavily', method: 'getAnswer')] final readonly class Tavily { /** @@ -62,4 +65,194 @@ public function extract(array $urls): string return $result->getContent(); } + + /** + * Search news using Tavily. + * + * @param string $query Search query + * @param int $maxResults Maximum number of results + * @param string $timeframe Timeframe filter (day, week, month, year) + * @param string $includeAnswer Include direct answer + * + * @return array{ + * query: string, + * answer: string|null, + * results: array, + * } + */ + public function searchNews( + string $query, + int $maxResults = 5, + string $timeframe = '', + string $includeAnswer = 'false', + ): array { + try { + $body = [ + 'query' => $query, + 'max_results' => min(max($maxResults, 1), 20), + 'search_depth' => 'basic', + 'include_answer' => 'true' === $includeAnswer, + 'search_type' => 'news', + 'api_key' => $this->apiKey, + ]; + + if ($timeframe) { + $body['timeframe'] = $timeframe; + } + + $response = $this->httpClient->request('POST', 'https://api.tavily.com/search', [ + 'json' => $body, + ]); + + $data = $response->toArray(); + + return [ + 'query' => $data['query'] ?? $query, + 'answer' => $data['answer'] ?? null, + 'results' => array_map(fn ($result) => [ + 'title' => $result['title'], + 'url' => $result['url'], + 'content' => $result['content'], + 'score' => $result['score'], + 'published_date' => $result['published_date'], + 'source' => $result['source'] ?? '', + ], $data['results'] ?? []), + ]; + } catch (\Exception $e) { + return [ + 'query' => $query, + 'answer' => null, + 'results' => [], + ]; + } + } + + /** + * Search documents using Tavily. + * + * @param string $query Search query + * @param array $urls URLs to search within + * @param int $maxResults Maximum number of results + * @param string $includeAnswer Include direct answer + * + * @return array{ + * query: string, + * answer: string|null, + * results: array, + * } + */ + public function searchDocuments( + string $query, + array $urls = [], + int $maxResults = 5, + string $includeAnswer = 'false', + ): array { + try { + $body = [ + 'query' => $query, + 'max_results' => min(max($maxResults, 1), 20), + 'search_depth' => 'advanced', + 'include_answer' => 'true' === $includeAnswer, + 'search_type' => 'document', + 'api_key' => $this->apiKey, + ]; + + if (!empty($urls)) { + $body['include_domains'] = $urls; + } + + $response = $this->httpClient->request('POST', 'https://api.tavily.com/search', [ + 'json' => $body, + ]); + + $data = $response->toArray(); + + return [ + 'query' => $data['query'] ?? $query, + 'answer' => $data['answer'] ?? null, + 'results' => array_map(fn ($result) => [ + 'title' => $result['title'], + 'url' => $result['url'], + 'content' => $result['content'], + 'score' => $result['score'], + 'published_date' => $result['published_date'] ?? null, + ], $data['results'] ?? []), + ]; + } catch (\Exception $e) { + return [ + 'query' => $query, + 'answer' => null, + 'results' => [], + ]; + } + } + + /** + * Get direct answer using Tavily. + * + * @param string $query Search query + * @param int $maxResults Maximum number of results to use for answer + * @param string $searchDepth Search depth (basic, advanced) + * + * @return array{ + * query: string, + * answer: string, + * sources: array, + * } + */ + public function getAnswer( + string $query, + int $maxResults = 5, + string $searchDepth = 'basic', + ): array { + try { + $body = [ + 'query' => $query, + 'max_results' => min(max($maxResults, 1), 20), + 'search_depth' => $searchDepth, + 'include_answer' => true, + 'include_raw_content' => false, + 'api_key' => $this->apiKey, + ]; + + $response = $this->httpClient->request('POST', 'https://api.tavily.com/search', [ + 'json' => $body, + ]); + + $data = $response->toArray(); + + return [ + 'query' => $data['query'] ?? $query, + 'answer' => $data['answer'] ?? 'No answer found', + 'sources' => array_map(fn ($result) => [ + 'title' => $result['title'], + 'url' => $result['url'], + 'score' => $result['score'], + ], $data['results'] ?? []), + ]; + } catch (\Exception $e) { + return [ + 'query' => $query, + 'answer' => 'Error retrieving answer: '.$e->getMessage(), + 'sources' => [], + ]; + } + } } diff --git a/src/agent/src/Toolbox/Tool/TelegramBot.php b/src/agent/src/Toolbox/Tool/TelegramBot.php new file mode 100644 index 000000000..ac429e954 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/TelegramBot.php @@ -0,0 +1,418 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('telegram_send_message', 'Tool that sends messages via Telegram bot')] +#[AsTool('telegram_send_photo', 'Tool that sends photos via Telegram bot', method: 'sendPhoto')] +#[AsTool('telegram_send_document', 'Tool that sends documents via Telegram bot', method: 'sendDocument')] +#[AsTool('telegram_get_updates', 'Tool that gets updates from Telegram bot', method: 'getUpdates')] +#[AsTool('telegram_get_chat_info', 'Tool that gets chat information from Telegram', method: 'getChatInfo')] +final readonly class TelegramBot +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $botToken, + private array $options = [], + ) { + } + + /** + * Send a message via Telegram bot. + * + * @param string $chatId Chat ID to send message to + * @param string $text Message text + * @param string $parseMode Parse mode (HTML, Markdown, MarkdownV2) + * @param bool $disableWebPagePreview Whether to disable web page preview + * @param bool $disableNotification Whether to disable notification + * @param int $replyToMessageId ID of the original message if replying + * + * @return array{ + * message_id: int, + * from: array{ + * id: int, + * is_bot: bool, + * first_name: string, + * username: string, + * }, + * chat: array{ + * id: int, + * type: string, + * title: string, + * username: string, + * }, + * date: int, + * text: string, + * }|string + */ + public function __invoke( + string $chatId, + #[With(maximum: 4096)] + string $text, + string $parseMode = 'HTML', + bool $disableWebPagePreview = false, + bool $disableNotification = false, + int $replyToMessageId = 0, + ): array|string { + try { + $payload = [ + 'chat_id' => $chatId, + 'text' => $text, + 'parse_mode' => $parseMode, + 'disable_web_page_preview' => $disableWebPagePreview, + 'disable_notification' => $disableNotification, + ]; + + if ($replyToMessageId > 0) { + $payload['reply_to_message_id'] = $replyToMessageId; + } + + $response = $this->httpClient->request('POST', "https://api.telegram.org/bot{$this->botToken}/sendMessage", [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (!$data['ok']) { + return 'Error sending message: '.($data['description'] ?? 'Unknown error'); + } + + $message = $data['result']; + + return [ + 'message_id' => $message['message_id'], + 'from' => [ + 'id' => $message['from']['id'], + 'is_bot' => $message['from']['is_bot'], + 'first_name' => $message['from']['first_name'], + 'username' => $message['from']['username'] ?? '', + ], + 'chat' => [ + 'id' => $message['chat']['id'], + 'type' => $message['chat']['type'], + 'title' => $message['chat']['title'] ?? '', + 'username' => $message['chat']['username'] ?? '', + ], + 'date' => $message['date'], + 'text' => $message['text'], + ]; + } catch (\Exception $e) { + return 'Error sending message: '.$e->getMessage(); + } + } + + /** + * Send a photo via Telegram bot. + * + * @param string $chatId Chat ID to send photo to + * @param string $photoPath Path to the photo file or photo URL + * @param string $caption Photo caption + * @param string $parseMode Parse mode for caption (HTML, Markdown, MarkdownV2) + * + * @return array|string + */ + public function sendPhoto( + string $chatId, + string $photoPath, + string $caption = '', + string $parseMode = 'HTML', + ): array|string { + try { + $payload = [ + 'chat_id' => $chatId, + 'caption' => $caption, + 'parse_mode' => $parseMode, + ]; + + // Check if it's a file path or URL + if (file_exists($photoPath)) { + $payload['photo'] = fopen($photoPath, 'r'); + } else { + $payload['photo'] = $photoPath; + } + + $response = $this->httpClient->request('POST', "https://api.telegram.org/bot{$this->botToken}/sendPhoto", [ + 'headers' => [ + 'Content-Type' => 'multipart/form-data', + ], + 'body' => $payload, + ]); + + $data = $response->toArray(); + + if (!$data['ok']) { + return 'Error sending photo: '.($data['description'] ?? 'Unknown error'); + } + + return $data['result']; + } catch (\Exception $e) { + return 'Error sending photo: '.$e->getMessage(); + } + } + + /** + * Send a document via Telegram bot. + * + * @param string $chatId Chat ID to send document to + * @param string $documentPath Path to the document file + * @param string $caption Document caption + * @param string $parseMode Parse mode for caption (HTML, Markdown, MarkdownV2) + * + * @return array|string + */ + public function sendDocument( + string $chatId, + string $documentPath, + string $caption = '', + string $parseMode = 'HTML', + ): array|string { + try { + if (!file_exists($documentPath)) { + return 'Error: Document file does not exist'; + } + + $payload = [ + 'chat_id' => $chatId, + 'document' => fopen($documentPath, 'r'), + 'caption' => $caption, + 'parse_mode' => $parseMode, + ]; + + $response = $this->httpClient->request('POST', "https://api.telegram.org/bot{$this->botToken}/sendDocument", [ + 'headers' => [ + 'Content-Type' => 'multipart/form-data', + ], + 'body' => $payload, + ]); + + $data = $response->toArray(); + + if (!$data['ok']) { + return 'Error sending document: '.($data['description'] ?? 'Unknown error'); + } + + return $data['result']; + } catch (\Exception $e) { + return 'Error sending document: '.$e->getMessage(); + } + } + + /** + * Get updates from Telegram bot. + * + * @param int $offset Identifier of the first update to be returned + * @param int $limit Limits the number of updates to be retrieved (1-100) + * @param int $timeout Timeout in seconds for long polling + * @param array $allowedUpdates List of update types to receive + * + * @return array|string + */ + public function getUpdates( + int $offset = 0, + int $limit = 100, + int $timeout = 0, + array $allowedUpdates = [], + ): array|string { + try { + $params = [ + 'offset' => $offset, + 'limit' => min(max($limit, 1), 100), + 'timeout' => $timeout, + ]; + + if (!empty($allowedUpdates)) { + $params['allowed_updates'] = $allowedUpdates; + } + + $response = $this->httpClient->request('GET', "https://api.telegram.org/bot{$this->botToken}/getUpdates", [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!$data['ok']) { + return 'Error getting updates: '.($data['description'] ?? 'Unknown error'); + } + + $updates = []; + foreach ($data['result'] as $update) { + if (isset($update['message'])) { + $updates[] = [ + 'update_id' => $update['update_id'], + 'message' => [ + 'message_id' => $update['message']['message_id'], + 'from' => [ + 'id' => $update['message']['from']['id'], + 'is_bot' => $update['message']['from']['is_bot'], + 'first_name' => $update['message']['from']['first_name'], + 'username' => $update['message']['from']['username'] ?? '', + ], + 'chat' => [ + 'id' => $update['message']['chat']['id'], + 'type' => $update['message']['chat']['type'], + 'title' => $update['message']['chat']['title'] ?? '', + 'username' => $update['message']['chat']['username'] ?? '', + ], + 'date' => $update['message']['date'], + 'text' => $update['message']['text'] ?? '', + ], + ]; + } + } + + return $updates; + } catch (\Exception $e) { + return 'Error getting updates: '.$e->getMessage(); + } + } + + /** + * Get chat information from Telegram. + * + * @param string $chatId Chat ID to get information for + * + * @return array{ + * id: int, + * type: string, + * title: string, + * username: string, + * first_name: string, + * last_name: string, + * description: string, + * invite_link: string, + * member_count: int, + * can_set_sticker_set: bool, + * sticker_set_name: string, + * }|string + */ + public function getChatInfo(string $chatId): array|string + { + try { + $response = $this->httpClient->request('GET', "https://api.telegram.org/bot{$this->botToken}/getChat", [ + 'query' => [ + 'chat_id' => $chatId, + ], + ]); + + $data = $response->toArray(); + + if (!$data['ok']) { + return 'Error getting chat info: '.($data['description'] ?? 'Unknown error'); + } + + $chat = $data['result']; + + return [ + 'id' => $chat['id'], + 'type' => $chat['type'], + 'title' => $chat['title'] ?? '', + 'username' => $chat['username'] ?? '', + 'first_name' => $chat['first_name'] ?? '', + 'last_name' => $chat['last_name'] ?? '', + 'description' => $chat['description'] ?? '', + 'invite_link' => $chat['invite_link'] ?? '', + 'member_count' => $chat['member_count'] ?? 0, + 'can_set_sticker_set' => $chat['can_set_sticker_set'] ?? false, + 'sticker_set_name' => $chat['sticker_set_name'] ?? '', + ]; + } catch (\Exception $e) { + return 'Error getting chat info: '.$e->getMessage(); + } + } + + /** + * Set webhook for Telegram bot. + * + * @param string $url HTTPS URL to send updates to + * @param string $certificate Path to public key certificate (optional) + */ + public function setWebhook(string $url, string $certificate = ''): string + { + try { + $payload = [ + 'url' => $url, + ]; + + if ($certificate && file_exists($certificate)) { + $payload['certificate'] = fopen($certificate, 'r'); + } + + $response = $this->httpClient->request('POST', "https://api.telegram.org/bot{$this->botToken}/setWebhook", [ + 'headers' => [ + 'Content-Type' => 'multipart/form-data', + ], + 'body' => $payload, + ]); + + $data = $response->toArray(); + + if (!$data['ok']) { + return 'Error setting webhook: '.($data['description'] ?? 'Unknown error'); + } + + return 'Webhook set successfully'; + } catch (\Exception $e) { + return 'Error setting webhook: '.$e->getMessage(); + } + } + + /** + * Delete webhook for Telegram bot. + */ + public function deleteWebhook(): string + { + try { + $response = $this->httpClient->request('POST', "https://api.telegram.org/bot{$this->botToken}/deleteWebhook"); + + $data = $response->toArray(); + + if (!$data['ok']) { + return 'Error deleting webhook: '.($data['description'] ?? 'Unknown error'); + } + + return 'Webhook deleted successfully'; + } catch (\Exception $e) { + return 'Error deleting webhook: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Terraform.php b/src/agent/src/Toolbox/Tool/Terraform.php new file mode 100644 index 000000000..58a140768 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Terraform.php @@ -0,0 +1,320 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('terraform_init', 'Tool that initializes Terraform workspace')] +#[AsTool('terraform_plan', 'Tool that creates Terraform execution plan', method: 'plan')] +#[AsTool('terraform_apply', 'Tool that applies Terraform configuration', method: 'apply')] +#[AsTool('terraform_destroy', 'Tool that destroys Terraform resources', method: 'destroy')] +#[AsTool('terraform_validate', 'Tool that validates Terraform configuration', method: 'validate')] +#[AsTool('terraform_show', 'Tool that shows Terraform state', method: 'show')] +final readonly class Terraform +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $workingDirectory = '.', + private array $options = [], + ) { + } + + /** + * Initialize Terraform workspace. + * + * @param string $backendConfig Backend configuration JSON + * @param string $upgrade Upgrade modules and plugins + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * }|string + */ + public function __invoke( + string $backendConfig = '', + string $upgrade = 'false', + ): array|string { + try { + $command = ['terraform', 'init']; + + if ('true' === $upgrade) { + $command[] = '-upgrade'; + } + + if ($backendConfig) { + $config = json_decode($backendConfig, true); + if ($config) { + foreach ($config as $key => $value) { + $command[] = "-backend-config={$key}={$value}"; + } + } + } + + $output = $this->executeCommand($command); + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Create Terraform execution plan. + * + * @param string $varFile Variable file path + * @param string $target Target resource + * @param string $out Output plan file + * @param string $destroy Destroy plan + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * planFile: string, + * }|string + */ + public function plan( + string $varFile = '', + string $target = '', + string $out = '', + string $destroy = 'false', + ): array|string { + try { + $command = ['terraform', 'plan']; + + if ($varFile) { + $command[] = "-var-file={$varFile}"; + } + + if ($target) { + $command[] = "-target={$target}"; + } + + if ($out) { + $command[] = "-out={$out}"; + } + + if ('true' === $destroy) { + $command[] = '-destroy'; + } + + $output = $this->executeCommand($command); + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + 'planFile' => $out ?: '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + 'planFile' => '', + ]; + } + } + + /** + * Apply Terraform configuration. + * + * @param string $planFile Plan file path + * @param string $autoApprove Auto approve changes + * @param string $target Target resource + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * }|string + */ + public function apply( + string $planFile = '', + string $autoApprove = 'false', + string $target = '', + ): array|string { + try { + $command = ['terraform', 'apply']; + + if ($planFile) { + $command[] = $planFile; + } else { + if ('true' === $autoApprove) { + $command[] = '-auto-approve'; + } + + if ($target) { + $command[] = "-target={$target}"; + } + } + + $output = $this->executeCommand($command); + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Destroy Terraform resources. + * + * @param string $autoApprove Auto approve destruction + * @param string $target Target resource + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * }|string + */ + public function destroy( + string $autoApprove = 'false', + string $target = '', + ): array|string { + try { + $command = ['terraform', 'destroy']; + + if ('true' === $autoApprove) { + $command[] = '-auto-approve'; + } + + if ($target) { + $command[] = "-target={$target}"; + } + + $output = $this->executeCommand($command); + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Validate Terraform configuration. + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * }|string + */ + public function validate(): array|string + { + try { + $command = ['terraform', 'validate']; + + $output = $this->executeCommand($command); + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Show Terraform state. + * + * @param string $resource Specific resource to show + * + * @return array{ + * success: bool, + * output: string, + * error: string, + * }|string + */ + public function show(string $resource = ''): array|string + { + try { + $command = ['terraform', 'show']; + + if ($resource) { + $command[] = $resource; + } + + $output = $this->executeCommand($command); + + return [ + 'success' => true, + 'output' => $output, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Execute Terraform command. + */ + private function executeCommand(array $command): string + { + $commandString = implode(' ', array_map('escapeshellarg', $command)); + + $output = []; + $returnCode = 0; + + exec("cd {$this->workingDirectory} && {$commandString} 2>&1", $output, $returnCode); + + if (0 !== $returnCode) { + throw new \RuntimeException('Terraform command failed: '.implode("\n", $output)); + } + + return implode("\n", $output); + } +} diff --git a/src/agent/src/Toolbox/Tool/TikTok.php b/src/agent/src/Toolbox/Tool/TikTok.php new file mode 100644 index 000000000..ba437fcdc --- /dev/null +++ b/src/agent/src/Toolbox/Tool/TikTok.php @@ -0,0 +1,548 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('tiktok_search_videos', 'Tool that searches for TikTok videos')] +#[AsTool('tiktok_get_user_info', 'Tool that gets TikTok user information', method: 'getUserInfo')] +#[AsTool('tiktok_get_user_videos', 'Tool that gets TikTok user videos', method: 'getUserVideos')] +#[AsTool('tiktok_get_video_info', 'Tool that gets TikTok video details', method: 'getVideoInfo')] +#[AsTool('tiktok_get_trending_videos', 'Tool that gets trending TikTok videos', method: 'getTrendingVideos')] +#[AsTool('tiktok_get_hashtag_videos', 'Tool that gets TikTok videos by hashtag', method: 'getHashtagVideos')] +final readonly class TikTok +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private string $apiVersion = 'v1.3', + private array $options = [], + ) { + } + + /** + * Search for TikTok videos. + * + * @param string $query Search query + * @param int $maxCount Maximum number of results (1-20) + * @param string $cursor Pagination cursor + * @param string $sortType Sort type (most_relevant, most_liked, most_shared, most_viewed, newest) + * @param string $publishTime Publish time filter (last_1_hour, last_24_hours, last_7_days, last_30_days, last_1_year) + * + * @return array, + * mentions: array, + * video_url: string, + * author: array{ + * id: string, + * unique_id: string, + * nickname: string, + * avatar_thumb: array{url_list: array}, + * follower_count: int, + * following_count: int, + * aweme_count: int, + * verification_info: array{type: int, desc: string}, + * }, + * }> + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + int $maxCount = 10, + string $cursor = '', + string $sortType = 'most_relevant', + string $publishTime = '', + ): array { + try { + $payload = [ + 'keyword' => $query, + 'max_count' => min(max($maxCount, 1), 20), + 'sort_type' => $sortType, + ]; + + if ($cursor) { + $payload['cursor'] = $cursor; + } + + if ($publishTime) { + $payload['publish_time'] = $publishTime; + } + + $response = $this->httpClient->request('POST', "https://open.tiktokapis.com/{$this->apiVersion}/research/video/query/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (!isset($data['data']['videos'])) { + return []; + } + + $videos = []; + foreach ($data['data']['videos'] as $video) { + $videos[] = [ + 'id' => $video['id'], + 'title' => $video['title'] ?? '', + 'video_description' => $video['video_description'] ?? '', + 'duration' => $video['duration'] ?? 0, + 'cover_image_url' => $video['cover_image_url'] ?? '', + 'embed_url' => $video['embed_url'] ?? '', + 'like_count' => $video['statistics']['like_count'] ?? 0, + 'comment_count' => $video['statistics']['comment_count'] ?? 0, + 'share_count' => $video['statistics']['share_count'] ?? 0, + 'view_count' => $video['statistics']['view_count'] ?? 0, + 'create_time' => $video['create_time'] ?? 0, + 'hashtag_names' => $video['text_extra'] ?? [], + 'mentions' => $video['text_extra'] ?? [], + 'video_url' => $video['video']['play_addr']['url_list'][0] ?? '', + 'author' => [ + 'id' => $video['author']['id'] ?? '', + 'unique_id' => $video['author']['unique_id'] ?? '', + 'nickname' => $video['author']['nickname'] ?? '', + 'avatar_thumb' => [ + 'url_list' => $video['author']['avatar_thumb']['url_list'] ?? [], + ], + 'follower_count' => $video['author']['follower_count'] ?? 0, + 'following_count' => $video['author']['following_count'] ?? 0, + 'aweme_count' => $video['author']['aweme_count'] ?? 0, + 'verification_info' => [ + 'type' => $video['author']['verification_info']['type'] ?? 0, + 'desc' => $video['author']['verification_info']['desc'] ?? '', + ], + ], + ]; + } + + return $videos; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get TikTok user information. + * + * @param string $username TikTok username + * + * @return array{ + * id: string, + * unique_id: string, + * nickname: string, + * avatar_thumb: array{url_list: array}, + * follower_count: int, + * following_count: int, + * aweme_count: int, + * verification_info: array{type: int, desc: string}, + * signature: string, + * create_time: int, + * custom_verify: string, + * enterprise_verify_reason: string, + * region: string, + * commerce_user_info: array{commerce_user: bool, ad_video_url: string, ad_web_url: string}, + * }|string + */ + public function getUserInfo(string $username): array|string + { + try { + $response = $this->httpClient->request('POST', "https://open.tiktokapis.com/{$this->apiVersion}/user/info/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'fields' => ['id', 'unique_id', 'nickname', 'avatar_thumb', 'follower_count', 'following_count', 'aweme_count', 'verification_info', 'signature', 'create_time', 'custom_verify', 'enterprise_verify_reason', 'region', 'commerce_user_info'], + 'username' => $username, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting user info: '.($data['error']['message'] ?? 'Unknown error'); + } + + $user = $data['data']['user']; + + return [ + 'id' => $user['id'], + 'unique_id' => $user['unique_id'], + 'nickname' => $user['nickname'], + 'avatar_thumb' => [ + 'url_list' => $user['avatar_thumb']['url_list'] ?? [], + ], + 'follower_count' => $user['follower_count'] ?? 0, + 'following_count' => $user['following_count'] ?? 0, + 'aweme_count' => $user['aweme_count'] ?? 0, + 'verification_info' => [ + 'type' => $user['verification_info']['type'] ?? 0, + 'desc' => $user['verification_info']['desc'] ?? '', + ], + 'signature' => $user['signature'] ?? '', + 'create_time' => $user['create_time'] ?? 0, + 'custom_verify' => $user['custom_verify'] ?? '', + 'enterprise_verify_reason' => $user['enterprise_verify_reason'] ?? '', + 'region' => $user['region'] ?? '', + 'commerce_user_info' => [ + 'commerce_user' => $user['commerce_user_info']['commerce_user'] ?? false, + 'ad_video_url' => $user['commerce_user_info']['ad_video_url'] ?? '', + 'ad_web_url' => $user['commerce_user_info']['ad_web_url'] ?? '', + ], + ]; + } catch (\Exception $e) { + return 'Error getting user info: '.$e->getMessage(); + } + } + + /** + * Get TikTok user videos. + * + * @param string $username TikTok username + * @param int $maxCount Maximum number of videos (1-20) + * @param string $cursor Pagination cursor + * + * @return array + */ + public function getUserVideos( + string $username, + int $maxCount = 10, + string $cursor = '', + ): array { + try { + $payload = [ + 'username' => $username, + 'max_count' => min(max($maxCount, 1), 20), + ]; + + if ($cursor) { + $payload['cursor'] = $cursor; + } + + $response = $this->httpClient->request('POST', "https://open.tiktokapis.com/{$this->apiVersion}/user/videos/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (!isset($data['data']['videos'])) { + return []; + } + + $videos = []; + foreach ($data['data']['videos'] as $video) { + $videos[] = [ + 'id' => $video['id'], + 'title' => $video['title'] ?? '', + 'video_description' => $video['video_description'] ?? '', + 'duration' => $video['duration'] ?? 0, + 'cover_image_url' => $video['cover_image_url'] ?? '', + 'like_count' => $video['statistics']['like_count'] ?? 0, + 'comment_count' => $video['statistics']['comment_count'] ?? 0, + 'share_count' => $video['statistics']['share_count'] ?? 0, + 'view_count' => $video['statistics']['view_count'] ?? 0, + 'create_time' => $video['create_time'] ?? 0, + 'video_url' => $video['video']['play_addr']['url_list'][0] ?? '', + ]; + } + + return $videos; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get TikTok video information. + * + * @param string $videoId TikTok video ID + * + * @return array{ + * id: string, + * title: string, + * video_description: string, + * duration: int, + * cover_image_url: string, + * embed_url: string, + * like_count: int, + * comment_count: int, + * share_count: int, + * view_count: int, + * create_time: int, + * hashtag_names: array, + * video_url: string, + * author: array{ + * id: string, + * unique_id: string, + * nickname: string, + * avatar_thumb: array{url_list: array}, + * follower_count: int, + * }, + * }|string + */ + public function getVideoInfo(string $videoId): array|string + { + try { + $response = $this->httpClient->request('POST', "https://open.tiktokapis.com/{$this->apiVersion}/video/info/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'video_id' => $videoId, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting video info: '.($data['error']['message'] ?? 'Unknown error'); + } + + $video = $data['data']['video']; + + return [ + 'id' => $video['id'], + 'title' => $video['title'] ?? '', + 'video_description' => $video['video_description'] ?? '', + 'duration' => $video['duration'] ?? 0, + 'cover_image_url' => $video['cover_image_url'] ?? '', + 'embed_url' => $video['embed_url'] ?? '', + 'like_count' => $video['statistics']['like_count'] ?? 0, + 'comment_count' => $video['statistics']['comment_count'] ?? 0, + 'share_count' => $video['statistics']['share_count'] ?? 0, + 'view_count' => $video['statistics']['view_count'] ?? 0, + 'create_time' => $video['create_time'] ?? 0, + 'hashtag_names' => $video['text_extra'] ?? [], + 'video_url' => $video['video']['play_addr']['url_list'][0] ?? '', + 'author' => [ + 'id' => $video['author']['id'] ?? '', + 'unique_id' => $video['author']['unique_id'] ?? '', + 'nickname' => $video['author']['nickname'] ?? '', + 'avatar_thumb' => [ + 'url_list' => $video['author']['avatar_thumb']['url_list'] ?? [], + ], + 'follower_count' => $video['author']['follower_count'] ?? 0, + ], + ]; + } catch (\Exception $e) { + return 'Error getting video info: '.$e->getMessage(); + } + } + + /** + * Get trending TikTok videos. + * + * @param int $maxCount Maximum number of videos (1-20) + * @param string $cursor Pagination cursor + * @param string $countryCode Country code for trending videos + * + * @return array + */ + public function getTrendingVideos( + int $maxCount = 10, + string $cursor = '', + string $countryCode = 'US', + ): array { + try { + $payload = [ + 'max_count' => min(max($maxCount, 1), 20), + 'country_code' => $countryCode, + ]; + + if ($cursor) { + $payload['cursor'] = $cursor; + } + + $response = $this->httpClient->request('POST', "https://open.tiktokapis.com/{$this->apiVersion}/discovery/trending/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (!isset($data['data']['videos'])) { + return []; + } + + $videos = []; + foreach ($data['data']['videos'] as $video) { + $videos[] = [ + 'id' => $video['id'], + 'title' => $video['title'] ?? '', + 'video_description' => $video['video_description'] ?? '', + 'duration' => $video['duration'] ?? 0, + 'cover_image_url' => $video['cover_image_url'] ?? '', + 'like_count' => $video['statistics']['like_count'] ?? 0, + 'comment_count' => $video['statistics']['comment_count'] ?? 0, + 'share_count' => $video['statistics']['share_count'] ?? 0, + 'view_count' => $video['statistics']['view_count'] ?? 0, + 'create_time' => $video['create_time'] ?? 0, + 'video_url' => $video['video']['play_addr']['url_list'][0] ?? '', + 'author' => [ + 'id' => $video['author']['id'] ?? '', + 'unique_id' => $video['author']['unique_id'] ?? '', + 'nickname' => $video['author']['nickname'] ?? '', + 'follower_count' => $video['author']['follower_count'] ?? 0, + ], + ]; + } + + return $videos; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get TikTok videos by hashtag. + * + * @param string $hashtag Hashtag name (without #) + * @param int $maxCount Maximum number of videos (1-20) + * @param string $cursor Pagination cursor + * + * @return array + */ + public function getHashtagVideos( + string $hashtag, + int $maxCount = 10, + string $cursor = '', + ): array { + try { + $hashtag = ltrim($hashtag, '#'); + + $payload = [ + 'hashtag_name' => $hashtag, + 'max_count' => min(max($maxCount, 1), 20), + ]; + + if ($cursor) { + $payload['cursor'] = $cursor; + } + + $response = $this->httpClient->request('POST', "https://open.tiktokapis.com/{$this->apiVersion}/discovery/hashtag/videos/", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (!isset($data['data']['videos'])) { + return []; + } + + $videos = []; + foreach ($data['data']['videos'] as $video) { + $videos[] = [ + 'id' => $video['id'], + 'title' => $video['title'] ?? '', + 'video_description' => $video['video_description'] ?? '', + 'duration' => $video['duration'] ?? 0, + 'cover_image_url' => $video['cover_image_url'] ?? '', + 'like_count' => $video['statistics']['like_count'] ?? 0, + 'comment_count' => $video['statistics']['comment_count'] ?? 0, + 'share_count' => $video['statistics']['share_count'] ?? 0, + 'view_count' => $video['statistics']['view_count'] ?? 0, + 'create_time' => $video['create_time'] ?? 0, + 'video_url' => $video['video']['play_addr']['url_list'][0] ?? '', + 'author' => [ + 'id' => $video['author']['id'] ?? '', + 'unique_id' => $video['author']['unique_id'] ?? '', + 'nickname' => $video['author']['nickname'] ?? '', + ], + ]; + } + + return $videos; + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Trello.php b/src/agent/src/Toolbox/Tool/Trello.php new file mode 100644 index 000000000..199783a16 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Trello.php @@ -0,0 +1,569 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('trello_get_cards', 'Tool that gets Trello cards')] +#[AsTool('trello_create_card', 'Tool that creates Trello cards', method: 'createCard')] +#[AsTool('trello_update_card', 'Tool that updates Trello cards', method: 'updateCard')] +#[AsTool('trello_get_lists', 'Tool that gets Trello lists', method: 'getLists')] +#[AsTool('trello_get_boards', 'Tool that gets Trello boards', method: 'getBoards')] +#[AsTool('trello_get_members', 'Tool that gets Trello members', method: 'getMembers')] +final readonly class Trello +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + #[\SensitiveParameter] private string $apiToken, + private string $apiVersion = '1', + private array $options = [], + ) { + } + + /** + * Get Trello cards. + * + * @param string $boardId Board ID to filter cards + * @param string $listId List ID to filter cards + * @param string $member Member ID to filter cards + * @param string $label Label to filter cards + * @param bool $open Include only open cards + * @param bool $closed Include only closed cards + * @param int $limit Number of cards to retrieve + * + * @return array, + * idMembers: array, + * idChecklists: array, + * idMembersVoted: array, + * idAttachmentCover: string|null, + * manualCoverAttachment: bool, + * url: string, + * shortUrl: string, + * labels: array, + * members: array, + * checklists: array, + * attachments: array, + * badges: array{ + * votes: int, + * viewingMemberVoted: bool, + * subscribed: bool, + * fogbugz: string, + * checkItems: int, + * checkItemsChecked: int, + * comments: int, + * attachments: int, + * description: bool, + * due: string|null, + * dueComplete: bool, + * }, + * }> + */ + public function __invoke( + string $boardId = '', + string $listId = '', + string $member = '', + string $label = '', + bool $open = true, + bool $closed = false, + int $limit = 100, + ): array { + try { + $params = [ + 'key' => $this->apiKey, + 'token' => $this->apiToken, + 'limit' => min(max($limit, 1), 1000), + 'fields' => 'id,name,desc,closed,idList,idBoard,pos,due,dueComplete,dateLastActivity,idLabels,idMembers,idChecklists,idMembersVoted,idAttachmentCover,manualCoverAttachment,url,shortUrl,labels,members,checklists,attachments,badges', + ]; + + if ($boardId) { + $params['idBoard'] = $boardId; + } + if ($listId) { + $params['idList'] = $listId; + } + if ($member) { + $params['idMembers'] = $member; + } + if ($label) { + $params['idLabels'] = $label; + } + + $response = $this->httpClient->request('GET', "https://api.trello.com/{$this->apiVersion}/search", [ + 'query' => array_merge($params, [ + 'query' => 'is:open', + 'modelTypes' => 'cards', + ], $this->options), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + $cards = $data['cards'] ?? []; + + // Filter by open/closed status + $filteredCards = array_filter($cards, fn ($card) => ($open && !$card['closed']) || ($closed && $card['closed']) + ); + + return array_map(fn ($card) => [ + 'id' => $card['id'], + 'name' => $card['name'], + 'desc' => $card['desc'] ?? '', + 'closed' => $card['closed'], + 'idList' => $card['idList'], + 'idBoard' => $card['idBoard'], + 'pos' => $card['pos'], + 'due' => $card['due'], + 'dueComplete' => $card['dueComplete'] ?? false, + 'dateLastActivity' => $card['dateLastActivity'], + 'idLabels' => $card['idLabels'] ?? [], + 'idMembers' => $card['idMembers'] ?? [], + 'idChecklists' => $card['idChecklists'] ?? [], + 'idMembersVoted' => $card['idMembersVoted'] ?? [], + 'idAttachmentCover' => $card['idAttachmentCover'], + 'manualCoverAttachment' => $card['manualCoverAttachment'] ?? false, + 'url' => $card['url'], + 'shortUrl' => $card['shortUrl'], + 'labels' => array_map(fn ($label) => [ + 'id' => $label['id'], + 'name' => $label['name'], + 'color' => $label['color'], + ], $card['labels'] ?? []), + 'members' => array_map(fn ($member) => [ + 'id' => $member['id'], + 'username' => $member['username'], + 'fullName' => $member['fullName'], + ], $card['members'] ?? []), + 'checklists' => array_map(fn ($checklist) => [ + 'id' => $checklist['id'], + 'name' => $checklist['name'], + ], $card['checklists'] ?? []), + 'attachments' => array_map(fn ($attachment) => [ + 'id' => $attachment['id'], + 'name' => $attachment['name'], + 'url' => $attachment['url'], + ], $card['attachments'] ?? []), + 'badges' => [ + 'votes' => $card['badges']['votes'] ?? 0, + 'viewingMemberVoted' => $card['badges']['viewingMemberVoted'] ?? false, + 'subscribed' => $card['badges']['subscribed'] ?? false, + 'fogbugz' => $card['badges']['fogbugz'] ?? '', + 'checkItems' => $card['badges']['checkItems'] ?? 0, + 'checkItemsChecked' => $card['badges']['checkItemsChecked'] ?? 0, + 'comments' => $card['badges']['comments'] ?? 0, + 'attachments' => $card['badges']['attachments'] ?? 0, + 'description' => $card['badges']['description'] ?? false, + 'due' => $card['badges']['due'], + 'dueComplete' => $card['badges']['dueComplete'] ?? false, + ], + ], $filteredCards); + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a Trello card. + * + * @param string $name Card name + * @param string $desc Card description + * @param string $idList List ID + * @param string $due Due date + * @param array $idMembers Member IDs + * @param array $idLabels Label IDs + * @param float $pos Position + * @param bool $subscribed Subscribe to card + * + * @return array{ + * id: string, + * name: string, + * desc: string, + * closed: bool, + * idList: string, + * idBoard: string, + * pos: float, + * due: string|null, + * dueComplete: bool, + * dateLastActivity: string, + * idLabels: array, + * idMembers: array, + * url: string, + * shortUrl: string, + * badges: array, + * }|string + */ + public function createCard( + string $name, + string $desc = '', + string $idList = '', + string $due = '', + array $idMembers = [], + array $idLabels = [], + float $pos = 0, + bool $subscribed = false, + ): array|string { + try { + $payload = [ + 'key' => $this->apiKey, + 'token' => $this->apiToken, + 'name' => $name, + ]; + + if ($desc) { + $payload['desc'] = $desc; + } + if ($idList) { + $payload['idList'] = $idList; + } + if ($due) { + $payload['due'] = $due; + } + if (!empty($idMembers)) { + $payload['idMembers'] = implode(',', $idMembers); + } + if (!empty($idLabels)) { + $payload['idLabels'] = implode(',', $idLabels); + } + if ($pos > 0) { + $payload['pos'] = $pos; + } + $payload['subscribed'] = $subscribed; + + $response = $this->httpClient->request('POST', "https://api.trello.com/{$this->apiVersion}/cards", [ + 'query' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating card: '.($data['error'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'desc' => $data['desc'] ?? '', + 'closed' => $data['closed'], + 'idList' => $data['idList'], + 'idBoard' => $data['idBoard'], + 'pos' => $data['pos'], + 'due' => $data['due'], + 'dueComplete' => $data['dueComplete'] ?? false, + 'dateLastActivity' => $data['dateLastActivity'], + 'idLabels' => $data['idLabels'] ?? [], + 'idMembers' => $data['idMembers'] ?? [], + 'url' => $data['url'], + 'shortUrl' => $data['shortUrl'], + 'badges' => $data['badges'] ?? [], + ]; + } catch (\Exception $e) { + return 'Error creating card: '.$e->getMessage(); + } + } + + /** + * Update a Trello card. + * + * @param string $cardId Card ID to update + * @param string $name New name (optional) + * @param string $desc New description (optional) + * @param string $due New due date (optional) + * @param bool $closed New closed status (optional) + * @param array $idMembers New member IDs (optional) + * @param array $idLabels New label IDs (optional) + */ + public function updateCard( + string $cardId, + string $name = '', + string $desc = '', + string $due = '', + bool $closed = false, + array $idMembers = [], + array $idLabels = [], + ): string { + try { + $payload = [ + 'key' => $this->apiKey, + 'token' => $this->apiToken, + ]; + + if ($name) { + $payload['name'] = $name; + } + if ($desc) { + $payload['desc'] = $desc; + } + if ($due) { + $payload['due'] = $due; + } + $payload['closed'] = $closed; + if (!empty($idMembers)) { + $payload['idMembers'] = implode(',', $idMembers); + } + if (!empty($idLabels)) { + $payload['idLabels'] = implode(',', $idLabels); + } + + $response = $this->httpClient->request('PUT', "https://api.trello.com/{$this->apiVersion}/cards/{$cardId}", [ + 'query' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error updating card: '.($data['error'] ?? 'Unknown error'); + } + + return 'Card updated successfully'; + } catch (\Exception $e) { + return 'Error updating card: '.$e->getMessage(); + } + } + + /** + * Get Trello lists. + * + * @param string $boardId Board ID to filter lists + * @param string $filter Filter (all, closed, none, open) + * + * @return array + */ + public function getLists( + string $boardId = '', + string $filter = 'open', + ): array { + try { + $params = [ + 'key' => $this->apiKey, + 'token' => $this->apiToken, + 'filter' => $filter, + 'fields' => 'id,name,closed,idBoard,pos,subscribed', + ]; + + $url = $boardId + ? "https://api.trello.com/{$this->apiVersion}/boards/{$boardId}/lists" + : "https://api.trello.com/{$this->apiVersion}/members/me/lists"; + + $response = $this->httpClient->request('GET', $url, [ + 'query' => array_merge($params, $this->options), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($list) => [ + 'id' => $list['id'], + 'name' => $list['name'], + 'closed' => $list['closed'], + 'idBoard' => $list['idBoard'], + 'pos' => $list['pos'], + 'subscribed' => $list['subscribed'] ?? false, + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Trello boards. + * + * @param string $filter Filter (all, closed, members, open, organization, pinned, public, starred, templates) + * @param string $fields Fields to return + * + * @return array|null, + * backgroundTile: bool, + * backgroundBrightness: string, + * backgroundColor: string, + * backgroundBottomColor: string, + * backgroundTopColor: string, + * canBePublic: bool, + * canBeOrg: bool, + * canBePrivate: bool, + * canInvite: bool, + * }, + * labelNames: array{ + * green: string, + * yellow: string, + * orange: string, + * red: string, + * purple: string, + * blue: string, + * sky: string, + * lime: string, + * pink: string, + * black: string, + * }, + * dateLastActivity: string, + * dateLastView: string, + * }> + */ + public function getBoards( + string $filter = 'open', + string $fields = 'id,name,desc,closed,idOrganization,pinned,url,shortUrl,prefs,labelNames,dateLastActivity,dateLastView', + ): array { + try { + $params = [ + 'key' => $this->apiKey, + 'token' => $this->apiToken, + 'filter' => $filter, + 'fields' => $fields, + ]; + + $response = $this->httpClient->request('GET', "https://api.trello.com/{$this->apiVersion}/members/me/boards", [ + 'query' => array_merge($params, $this->options), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($board) => [ + 'id' => $board['id'], + 'name' => $board['name'], + 'desc' => $board['desc'] ?? '', + 'closed' => $board['closed'], + 'idOrganization' => $board['idOrganization'], + 'pinned' => $board['pinned'], + 'url' => $board['url'], + 'shortUrl' => $board['shortUrl'], + 'prefs' => $board['prefs'], + 'labelNames' => $board['labelNames'], + 'dateLastActivity' => $board['dateLastActivity'], + 'dateLastView' => $board['dateLastView'], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Trello members. + * + * @param string $boardId Board ID to filter members + * @param string $fields Fields to return + * + * @return array, + * idOrganizations: array, + * loginTypes: array, + * newEmail: string|null, + * status: string, + * }> + */ + public function getMembers( + string $boardId = '', + string $fields = 'id,username,fullName,initials,avatarHash,avatarUrl,email,idBoards,idOrganizations,loginTypes,newEmail,status', + ): array { + try { + $params = [ + 'key' => $this->apiKey, + 'token' => $this->apiToken, + 'fields' => $fields, + ]; + + $url = $boardId + ? "https://api.trello.com/{$this->apiVersion}/boards/{$boardId}/members" + : "https://api.trello.com/{$this->apiVersion}/members/me"; + + $response = $this->httpClient->request('GET', $url, [ + 'query' => array_merge($params, $this->options), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + $members = \is_array($data) && isset($data[0]) ? $data : [$data]; + + return array_map(fn ($member) => [ + 'id' => $member['id'], + 'username' => $member['username'], + 'fullName' => $member['fullName'], + 'initials' => $member['initials'], + 'avatarHash' => $member['avatarHash'], + 'avatarUrl' => $member['avatarUrl'], + 'email' => $member['email'] ?? '', + 'idBoards' => $member['idBoards'] ?? [], + 'idOrganizations' => $member['idOrganizations'] ?? [], + 'loginTypes' => $member['loginTypes'] ?? [], + 'newEmail' => $member['newEmail'], + 'status' => $member['status'], + ], $members); + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/TwilioSms.php b/src/agent/src/Toolbox/Tool/TwilioSms.php new file mode 100644 index 000000000..2d457b8ae --- /dev/null +++ b/src/agent/src/Toolbox/Tool/TwilioSms.php @@ -0,0 +1,507 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('twilio_send_sms', 'Tool that sends SMS messages via Twilio')] +#[AsTool('twilio_send_whatsapp', 'Tool that sends WhatsApp messages via Twilio', method: 'sendWhatsApp')] +#[AsTool('twilio_make_call', 'Tool that makes voice calls via Twilio', method: 'makeCall')] +#[AsTool('twilio_get_messages', 'Tool that retrieves SMS messages from Twilio', method: 'getMessages')] +#[AsTool('twilio_get_phone_numbers', 'Tool that lists Twilio phone numbers', method: 'getPhoneNumbers')] +#[AsTool('twilio_send_verification', 'Tool that sends verification codes via Twilio', method: 'sendVerification')] +final readonly class TwilioSms +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accountSid, + #[\SensitiveParameter] private string $authToken, + private string $fromNumber = '', + private array $options = [], + ) { + } + + /** + * Send SMS message via Twilio. + * + * @param string $to Recipient phone number (with country code, no +) + * @param string $message SMS message content + * @param string $from Sender phone number (optional, uses default if not provided) + * @param string $statusCallback Optional status callback URL + * + * @return array{ + * sid: string, + * account_sid: string, + * to: string, + * from: string, + * body: string, + * status: string, + * date_created: string, + * date_sent: string|null, + * date_updated: string, + * direction: string, + * price: string, + * price_unit: string, + * uri: string, + * }|string + */ + public function __invoke( + string $to, + #[With(maximum: 1600)] + string $message, + string $from = '', + string $statusCallback = '', + ): array|string { + try { + $fromNumber = $from ?: $this->fromNumber; + + if (!$fromNumber) { + return 'Error: No sender phone number provided'; + } + + $payload = [ + 'To' => $to, + 'From' => $fromNumber, + 'Body' => $message, + ]; + + if ($statusCallback) { + $payload['StatusCallback'] = $statusCallback; + } + + $response = $this->httpClient->request('POST', "https://api.twilio.com/2010-04-01/Accounts/{$this->accountSid}/Messages.json", [ + 'auth_basic' => [$this->accountSid, $this->authToken], + 'body' => http_build_query($payload), + ]); + + $data = $response->toArray(); + + if (isset($data['error_code'])) { + return 'Error sending SMS: '.($data['error_message'] ?? 'Unknown error'); + } + + return [ + 'sid' => $data['sid'], + 'account_sid' => $data['account_sid'], + 'to' => $data['to'], + 'from' => $data['from'], + 'body' => $data['body'], + 'status' => $data['status'], + 'date_created' => $data['date_created'], + 'date_sent' => $data['date_sent'] ?? null, + 'date_updated' => $data['date_updated'], + 'direction' => $data['direction'], + 'price' => $data['price'], + 'price_unit' => $data['price_unit'], + 'uri' => $data['uri'], + ]; + } catch (\Exception $e) { + return 'Error sending SMS: '.$e->getMessage(); + } + } + + /** + * Send WhatsApp message via Twilio. + * + * @param string $to Recipient WhatsApp number (with country code, no +) + * @param string $message WhatsApp message content + * @param string $from Sender WhatsApp number (optional, uses default if not provided) + * + * @return array{ + * sid: string, + * account_sid: string, + * to: string, + * from: string, + * body: string, + * status: string, + * date_created: string, + * date_sent: string|null, + * date_updated: string, + * direction: string, + * price: string, + * price_unit: string, + * }|string + */ + public function sendWhatsApp( + string $to, + #[With(maximum: 4096)] + string $message, + string $from = '', + ): array|string { + try { + $fromNumber = $from ?: $this->fromNumber; + + if (!$fromNumber) { + return 'Error: No sender WhatsApp number provided'; + } + + // Format WhatsApp numbers + $toWhatsApp = 'whatsapp:+'.ltrim($to, '+'); + $fromWhatsApp = 'whatsapp:+'.ltrim($fromNumber, '+'); + + $payload = [ + 'To' => $toWhatsApp, + 'From' => $fromWhatsApp, + 'Body' => $message, + ]; + + $response = $this->httpClient->request('POST', "https://api.twilio.com/2010-04-01/Accounts/{$this->accountSid}/Messages.json", [ + 'auth_basic' => [$this->accountSid, $this->authToken], + 'body' => http_build_query($payload), + ]); + + $data = $response->toArray(); + + if (isset($data['error_code'])) { + return 'Error sending WhatsApp message: '.($data['error_message'] ?? 'Unknown error'); + } + + return [ + 'sid' => $data['sid'], + 'account_sid' => $data['account_sid'], + 'to' => $data['to'], + 'from' => $data['from'], + 'body' => $data['body'], + 'status' => $data['status'], + 'date_created' => $data['date_created'], + 'date_sent' => $data['date_sent'] ?? null, + 'date_updated' => $data['date_updated'], + 'direction' => $data['direction'], + 'price' => $data['price'], + 'price_unit' => $data['price_unit'], + ]; + } catch (\Exception $e) { + return 'Error sending WhatsApp message: '.$e->getMessage(); + } + } + + /** + * Make a voice call via Twilio. + * + * @param string $to Recipient phone number + * @param string $twimlUrl TwiML URL for call instructions + * @param string $from Sender phone number (optional, uses default if not provided) + * @param string $record Whether to record the call (do-not-record, record-from-ringing, record-from-answer) + * + * @return array{ + * sid: string, + * account_sid: string, + * to: string, + * from: string, + * status: string, + * start_time: string|null, + * end_time: string|null, + * duration: string|null, + * price: string, + * price_unit: string, + * direction: string, + * answered_by: string|null, + * uri: string, + * }|string + */ + public function makeCall( + string $to, + string $twimlUrl, + string $from = '', + string $record = 'do-not-record', + ): array|string { + try { + $fromNumber = $from ?: $this->fromNumber; + + if (!$fromNumber) { + return 'Error: No sender phone number provided'; + } + + $payload = [ + 'To' => $to, + 'From' => $fromNumber, + 'Url' => $twimlUrl, + 'Record' => $record, + ]; + + $response = $this->httpClient->request('POST', "https://api.twilio.com/2010-04-01/Accounts/{$this->accountSid}/Calls.json", [ + 'auth_basic' => [$this->accountSid, $this->authToken], + 'body' => http_build_query($payload), + ]); + + $data = $response->toArray(); + + if (isset($data['error_code'])) { + return 'Error making call: '.($data['error_message'] ?? 'Unknown error'); + } + + return [ + 'sid' => $data['sid'], + 'account_sid' => $data['account_sid'], + 'to' => $data['to'], + 'from' => $data['from'], + 'status' => $data['status'], + 'start_time' => $data['start_time'] ?? null, + 'end_time' => $data['end_time'] ?? null, + 'duration' => $data['duration'] ?? null, + 'price' => $data['price'], + 'price_unit' => $data['price_unit'], + 'direction' => $data['direction'], + 'answered_by' => $data['answered_by'] ?? null, + 'uri' => $data['uri'], + ]; + } catch (\Exception $e) { + return 'Error making call: '.$e->getMessage(); + } + } + + /** + * Get SMS messages from Twilio. + * + * @param int $limit Maximum number of messages to retrieve + * @param string $to Filter by recipient number + * @param string $from Filter by sender number + * @param string $status Filter by message status + * + * @return array + */ + public function getMessages( + int $limit = 20, + string $to = '', + string $from = '', + string $status = '', + ): array { + try { + $params = [ + 'PageSize' => min(max($limit, 1), 1000), + ]; + + if ($to) { + $params['To'] = $to; + } + if ($from) { + $params['From'] = $from; + } + if ($status) { + $params['MessageStatus'] = $status; + } + + $response = $this->httpClient->request('GET', "https://api.twilio.com/2010-04-01/Accounts/{$this->accountSid}/Messages.json", [ + 'auth_basic' => [$this->accountSid, $this->authToken], + 'query' => $params, + ]); + + $data = $response->toArray(); + + if (!isset($data['messages'])) { + return []; + } + + $messages = []; + foreach ($data['messages'] as $message) { + $messages[] = [ + 'sid' => $message['sid'], + 'account_sid' => $message['account_sid'], + 'to' => $message['to'], + 'from' => $message['from'], + 'body' => $message['body'], + 'status' => $message['status'], + 'date_created' => $message['date_created'], + 'date_sent' => $message['date_sent'] ?? null, + 'date_updated' => $message['date_updated'], + 'direction' => $message['direction'], + 'price' => $message['price'], + 'price_unit' => $message['price_unit'], + ]; + } + + return $messages; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Twilio phone numbers. + * + * @param int $limit Maximum number of phone numbers to retrieve + * + * @return array + */ + public function getPhoneNumbers(int $limit = 20): array + { + try { + $response = $this->httpClient->request('GET', "https://api.twilio.com/2010-04-01/Accounts/{$this->accountSid}/IncomingPhoneNumbers.json", [ + 'auth_basic' => [$this->accountSid, $this->authToken], + 'query' => [ + 'PageSize' => min(max($limit, 1), 1000), + ], + ]); + + $data = $response->toArray(); + + if (!isset($data['incoming_phone_numbers'])) { + return []; + } + + $phoneNumbers = []; + foreach ($data['incoming_phone_numbers'] as $number) { + $phoneNumbers[] = [ + 'sid' => $number['sid'], + 'account_sid' => $number['account_sid'], + 'friendly_name' => $number['friendly_name'], + 'phone_number' => $number['phone_number'], + 'voice_url' => $number['voice_url'] ?? null, + 'voice_method' => $number['voice_method'], + 'voice_fallback_url' => $number['voice_fallback_url'] ?? null, + 'voice_fallback_method' => $number['voice_fallback_method'], + 'sms_url' => $number['sms_url'] ?? null, + 'sms_method' => $number['sms_method'], + 'sms_fallback_url' => $number['sms_fallback_url'] ?? null, + 'sms_fallback_method' => $number['sms_fallback_method'], + 'status_callback' => $number['status_callback'] ?? null, + 'status_callback_method' => $number['status_callback_method'], + 'capabilities' => [ + 'voice' => $number['capabilities']['voice'], + 'sms' => $number['capabilities']['sms'], + 'mms' => $number['capabilities']['mms'], + 'fax' => $number['capabilities']['fax'], + ], + 'date_created' => $number['date_created'], + 'date_updated' => $number['date_updated'], + ]; + } + + return $phoneNumbers; + } catch (\Exception $e) { + return []; + } + } + + /** + * Send verification code via Twilio. + * + * @param string $to Recipient phone number + * @param string $channel Verification channel (sms, call, email, whatsapp) + * @param string $locale Language locale (en, es, fr, etc.) + * + * @return array{ + * sid: string, + * service_sid: string, + * account_sid: string, + * to: string, + * channel: string, + * status: string, + * valid: bool, + * date_created: string, + * date_updated: string, + * lookup: array{carrier: array{error_code: string|null, name: string, mobile_country_code: string, mobile_network_code: string, type: string}}, + * amount: string|null, + * payee: string, + * send_code_attempts: array, + * }|string + */ + public function sendVerification( + string $to, + string $channel = 'sms', + string $locale = 'en', + ): array|string { + try { + $payload = [ + 'To' => $to, + 'Channel' => $channel, + 'Locale' => $locale, + ]; + + $response = $this->httpClient->request('POST', "https://verify.twilio.com/v2/Services/{$this->getVerifyServiceSid()}/Verifications", [ + 'auth_basic' => [$this->accountSid, $this->authToken], + 'body' => http_build_query($payload), + ]); + + $data = $response->toArray(); + + if (isset($data['error_code'])) { + return 'Error sending verification: '.($data['error_message'] ?? 'Unknown error'); + } + + return [ + 'sid' => $data['sid'], + 'service_sid' => $data['service_sid'], + 'account_sid' => $data['account_sid'], + 'to' => $data['to'], + 'channel' => $data['channel'], + 'status' => $data['status'], + 'valid' => $data['valid'], + 'date_created' => $data['date_created'], + 'date_updated' => $data['date_updated'], + 'lookup' => [ + 'carrier' => [ + 'error_code' => $data['lookup']['carrier']['error_code'] ?? null, + 'name' => $data['lookup']['carrier']['name'], + 'mobile_country_code' => $data['lookup']['carrier']['mobile_country_code'], + 'mobile_network_code' => $data['lookup']['carrier']['mobile_network_code'], + 'type' => $data['lookup']['carrier']['type'], + ], + ], + 'amount' => $data['amount'] ?? null, + 'payee' => $data['payee'], + 'send_code_attempts' => $data['send_code_attempts'] ?? [], + ]; + } catch (\Exception $e) { + return 'Error sending verification: '.$e->getMessage(); + } + } + + /** + * Get Twilio Verify service SID (placeholder - in real implementation, you'd configure this). + */ + private function getVerifyServiceSid(): string + { + // In a real implementation, you would store this as a configuration value + // or retrieve it from your application settings + return 'VA'.str_repeat('X', 32); // Placeholder format + } +} diff --git a/src/agent/src/Toolbox/Tool/Twitter.php b/src/agent/src/Toolbox/Tool/Twitter.php new file mode 100644 index 000000000..00bdfe9a5 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Twitter.php @@ -0,0 +1,548 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('twitter_search_tweets', 'Tool that searches for tweets on Twitter/X')] +#[AsTool('twitter_post_tweet', 'Tool that posts tweets to Twitter/X', method: 'postTweet')] +#[AsTool('twitter_get_user_timeline', 'Tool that gets user timeline from Twitter/X', method: 'getUserTimeline')] +#[AsTool('twitter_get_trending_topics', 'Tool that gets trending topics on Twitter/X', method: 'getTrendingTopics')] +#[AsTool('twitter_get_user_info', 'Tool that gets user information from Twitter/X', method: 'getUserInfo')] +#[AsTool('twitter_retweet', 'Tool that retweets a tweet on Twitter/X', method: 'retweet')] +#[AsTool('twitter_like_tweet', 'Tool that likes a tweet on Twitter/X', method: 'likeTweet')] +final readonly class Twitter +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $bearerToken, + #[\SensitiveParameter] private string $apiKey = '', + #[\SensitiveParameter] private string $apiSecret = '', + #[\SensitiveParameter] private string $accessToken = '', + #[\SensitiveParameter] private string $accessTokenSecret = '', + private array $options = [], + ) { + } + + /** + * Search for tweets on Twitter/X. + * + * @param string $query Search query + * @param int $maxResults Maximum number of results (10-100) + * @param string $lang Language code (e.g., 'en', 'es', 'fr') + * @param string $resultType Type of results (mixed, recent, popular) + * @param string $until Return tweets before this date (YYYY-MM-DD) + * @param string $since Return tweets after this date (YYYY-MM-DD) + * + * @return array, + * mentions: array, + * urls: array, + * }, + * }> + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + int $maxResults = 10, + string $lang = 'en', + string $resultType = 'recent', + string $until = '', + string $since = '', + ): array { + try { + $params = [ + 'query' => $query, + 'max_results' => min(max($maxResults, 10), 100), + 'tweet.fields' => 'id,text,author_id,created_at,public_metrics,lang,possibly_sensitive,entities', + 'user.fields' => 'id,name,username,verified,public_metrics', + 'expansions' => 'author_id', + ]; + + if ($lang) { + $params['lang'] = $lang; + } + if ($resultType) { + $params['result_type'] = $resultType; + } + if ($until) { + $params['until'] = $until; + } + if ($since) { + $params['since'] = $since; + } + + $response = $this->httpClient->request('GET', 'https://api.twitter.com/2/tweets/search/recent', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->bearerToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return []; + } + + $tweets = []; + foreach ($data['data'] as $tweet) { + $tweets[] = [ + 'id' => $tweet['id'], + 'text' => $tweet['text'], + 'author_id' => $tweet['author_id'], + 'created_at' => $tweet['created_at'], + 'public_metrics' => [ + 'retweet_count' => $tweet['public_metrics']['retweet_count'] ?? 0, + 'like_count' => $tweet['public_metrics']['like_count'] ?? 0, + 'reply_count' => $tweet['public_metrics']['reply_count'] ?? 0, + 'quote_count' => $tweet['public_metrics']['quote_count'] ?? 0, + ], + 'lang' => $tweet['lang'] ?? 'en', + 'possibly_sensitive' => $tweet['possibly_sensitive'] ?? false, + 'entities' => [ + 'hashtags' => array_map(fn ($hashtag) => [ + 'tag' => $hashtag['tag'], + 'start' => $hashtag['start'], + 'end' => $hashtag['end'], + ], $tweet['entities']['hashtags'] ?? []), + 'mentions' => array_map(fn ($mention) => [ + 'username' => $mention['username'], + 'start' => $mention['start'], + 'end' => $mention['end'], + ], $tweet['entities']['mentions'] ?? []), + 'urls' => array_map(fn ($url) => [ + 'url' => $url['url'], + 'expanded_url' => $url['expanded_url'] ?? '', + 'display_url' => $url['display_url'] ?? '', + ], $tweet['entities']['urls'] ?? []), + ], + ]; + } + + return $tweets; + } catch (\Exception $e) { + return []; + } + } + + /** + * Post a tweet to Twitter/X. + * + * @param string $text Tweet text content + * @param array $mediaIds Optional media IDs to attach + * @param string $replyToTweetId Optional tweet ID to reply to + * + * @return array{ + * id: string, + * text: string, + * created_at: string, + * }|string + */ + public function postTweet( + #[With(maximum: 280)] + string $text, + array $mediaIds = [], + string $replyToTweetId = '', + ): array|string { + try { + if (empty($this->accessToken) || empty($this->accessTokenSecret)) { + return 'Error: OAuth credentials required for posting tweets'; + } + + $tweetData = [ + 'text' => $text, + ]; + + if (!empty($mediaIds)) { + $tweetData['media'] = [ + 'media_ids' => $mediaIds, + ]; + } + + if ($replyToTweetId) { + $tweetData['reply'] = [ + 'in_reply_to_tweet_id' => $replyToTweetId, + ]; + } + + $response = $this->httpClient->request('POST', 'https://api.twitter.com/2/tweets', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ], + 'json' => $tweetData, + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error posting tweet: '.implode(', ', array_column($data['errors'], 'detail')); + } + + return [ + 'id' => $data['data']['id'], + 'text' => $data['data']['text'], + 'created_at' => $data['data']['created_at'] ?? date('c'), + ]; + } catch (\Exception $e) { + return 'Error posting tweet: '.$e->getMessage(); + } + } + + /** + * Get user timeline from Twitter/X. + * + * @param string $userId User ID to get timeline for + * @param int $maxResults Maximum number of tweets (5-100) + * @param string $since Return tweets after this date (YYYY-MM-DD) + * @param string $until Return tweets before this date (YYYY-MM-DD) + * + * @return array + */ + public function getUserTimeline( + string $userId, + int $maxResults = 10, + string $since = '', + string $until = '', + ): array { + try { + $params = [ + 'max_results' => min(max($maxResults, 5), 100), + 'tweet.fields' => 'id,text,created_at,public_metrics,lang', + ]; + + if ($since) { + $params['start_time'] = $since.'T00:00:00Z'; + } + if ($until) { + $params['end_time'] = $until.'T23:59:59Z'; + } + + $response = $this->httpClient->request('GET', "https://api.twitter.com/2/users/{$userId}/tweets", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->bearerToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return []; + } + + $tweets = []; + foreach ($data['data'] as $tweet) { + $tweets[] = [ + 'id' => $tweet['id'], + 'text' => $tweet['text'], + 'created_at' => $tweet['created_at'], + 'public_metrics' => [ + 'retweet_count' => $tweet['public_metrics']['retweet_count'] ?? 0, + 'like_count' => $tweet['public_metrics']['like_count'] ?? 0, + 'reply_count' => $tweet['public_metrics']['reply_count'] ?? 0, + 'quote_count' => $tweet['public_metrics']['quote_count'] ?? 0, + ], + 'lang' => $tweet['lang'] ?? 'en', + ]; + } + + return $tweets; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get trending topics on Twitter/X. + * + * @param int $woeid Where On Earth ID (1 = worldwide, 23424977 = US, etc.) + * + * @return array|string + */ + public function getTrendingTopics(int $woeid = 1): array|string + { + try { + $response = $this->httpClient->request('GET', 'https://api.twitter.com/1.1/trends/place.json', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->bearerToken, + ], + 'query' => [ + 'id' => $woeid, + ], + ]); + + $data = $response->toArray(); + + if (empty($data) || !isset($data[0]['trends'])) { + return []; + } + + $trends = []; + foreach ($data[0]['trends'] as $trend) { + $trends[] = [ + 'name' => $trend['name'], + 'url' => $trend['url'], + 'promoted_content' => $trend['promoted_content'] ?? null, + 'query' => $trend['query'], + 'tweet_volume' => $trend['tweet_volume'] ?? 0, + ]; + } + + return $trends; + } catch (\Exception $e) { + return 'Error getting trending topics: '.$e->getMessage(); + } + } + + /** + * Get user information from Twitter/X. + * + * @param string $username Username to get information for + * + * @return array{ + * id: string, + * name: string, + * username: string, + * description: string, + * location: string, + * url: string, + * verified: bool, + * public_metrics: array{ + * followers_count: int, + * following_count: int, + * tweet_count: int, + * listed_count: int, + * }, + * created_at: string, + * profile_image_url: string, + * }|string + */ + public function getUserInfo(string $username): array|string + { + try { + $response = $this->httpClient->request('GET', 'https://api.twitter.com/2/users/by/username/'.ltrim($username, '@'), [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->bearerToken, + ], + 'query' => [ + 'user.fields' => 'id,name,username,description,location,url,verified,public_metrics,created_at,profile_image_url', + ], + ]); + + $data = $response->toArray(); + + if (!isset($data['data'])) { + return 'Error: User not found'; + } + + $user = $data['data']; + + return [ + 'id' => $user['id'], + 'name' => $user['name'], + 'username' => $user['username'], + 'description' => $user['description'] ?? '', + 'location' => $user['location'] ?? '', + 'url' => $user['url'] ?? '', + 'verified' => $user['verified'] ?? false, + 'public_metrics' => [ + 'followers_count' => $user['public_metrics']['followers_count'] ?? 0, + 'following_count' => $user['public_metrics']['following_count'] ?? 0, + 'tweet_count' => $user['public_metrics']['tweet_count'] ?? 0, + 'listed_count' => $user['public_metrics']['listed_count'] ?? 0, + ], + 'created_at' => $user['created_at'], + 'profile_image_url' => $user['profile_image_url'] ?? '', + ]; + } catch (\Exception $e) { + return 'Error getting user info: '.$e->getMessage(); + } + } + + /** + * Retweet a tweet on Twitter/X. + * + * @param string $tweetId Tweet ID to retweet + * + * @return array{ + * id: string, + * text: string, + * created_at: string, + * }|string + */ + public function retweet(string $tweetId): array|string + { + try { + if (empty($this->accessToken) || empty($this->accessTokenSecret)) { + return 'Error: OAuth credentials required for retweeting'; + } + + $response = $this->httpClient->request('POST', 'https://api.twitter.com/2/users/me/retweets', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'tweet_id' => $tweetId, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error retweeting: '.implode(', ', array_column($data['errors'], 'detail')); + } + + return [ + 'id' => $data['data']['id'], + 'text' => $data['data']['text'] ?? '', + 'created_at' => $data['data']['created_at'] ?? date('c'), + ]; + } catch (\Exception $e) { + return 'Error retweeting: '.$e->getMessage(); + } + } + + /** + * Like a tweet on Twitter/X. + * + * @param string $tweetId Tweet ID to like + * + * @return array{ + * liked: bool, + * }|string + */ + public function likeTweet(string $tweetId): array|string + { + try { + if (empty($this->accessToken) || empty($this->accessTokenSecret)) { + return 'Error: OAuth credentials required for liking tweets'; + } + + $response = $this->httpClient->request('POST', 'https://api.twitter.com/2/users/me/likes', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'tweet_id' => $tweetId, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error liking tweet: '.implode(', ', array_column($data['errors'], 'detail')); + } + + return [ + 'liked' => true, + ]; + } catch (\Exception $e) { + return 'Error liking tweet: '.$e->getMessage(); + } + } + + /** + * Upload media to Twitter/X. + * + * @param string $filePath Path to the media file + * @param string $mediaType Media type (image/jpeg, image/png, video/mp4, etc.) + * + * @return array{ + * media_id: string, + * media_id_string: string, + * size: int, + * expires_after_secs: int, + * }|string + */ + public function uploadMedia(string $filePath, string $mediaType = 'image/jpeg'): array|string + { + try { + if (!file_exists($filePath)) { + return 'Error: Media file does not exist'; + } + + if (empty($this->accessToken) || empty($this->accessTokenSecret)) { + return 'Error: OAuth credentials required for uploading media'; + } + + $fileContent = file_get_contents($filePath); + $base64Content = base64_encode($fileContent); + + $response = $this->httpClient->request('POST', 'https://upload.twitter.com/1.1/media/upload.json', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => [ + 'media_data' => $base64Content, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error uploading media: '.implode(', ', array_column($data['errors'], 'message')); + } + + return [ + 'media_id' => $data['media_id'], + 'media_id_string' => $data['media_id_string'], + 'size' => $data['size'], + 'expires_after_secs' => $data['expires_after_secs'] ?? 0, + ]; + } catch (\Exception $e) { + return 'Error uploading media: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Vault.php b/src/agent/src/Toolbox/Tool/Vault.php new file mode 100644 index 000000000..1f474dd2c --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Vault.php @@ -0,0 +1,470 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('vault_read_secret', 'Tool that reads secrets from Vault')] +#[AsTool('vault_write_secret', 'Tool that writes secrets to Vault', method: 'writeSecret')] +#[AsTool('vault_delete_secret', 'Tool that deletes secrets from Vault', method: 'deleteSecret')] +#[AsTool('vault_list_secrets', 'Tool that lists secrets in Vault', method: 'listSecrets')] +#[AsTool('vault_auth_token', 'Tool that authenticates with Vault token', method: 'authToken')] +#[AsTool('vault_auth_userpass', 'Tool that authenticates with username/password', method: 'authUserpass')] +final readonly class Vault +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $baseUrl = 'http://localhost:8200', + #[\SensitiveParameter] private string $token = '', + private string $apiVersion = 'v1', + private array $options = [], + ) { + } + + /** + * Read secret from Vault. + * + * @param string $path Secret path + * @param string $version Secret version (KV v2) + * + * @return array{ + * data: array, + * metadata: array{ + * created_time: string, + * custom_metadata: array, + * deletion_time: string, + * destroyed: bool, + * version: int, + * }, + * lease_duration: int, + * lease_id: string, + * renewable: bool, + * request_id: string, + * warnings: array, + * wrap_info: array|null, + * }|string + */ + public function __invoke( + string $path, + string $version = '', + ): array|string { + try { + $params = []; + + if ($version) { + $params['version'] = $version; + } + + $headers = [ + 'Content-Type' => 'application/json', + 'X-Vault-Request' => 'true', + ]; + + if ($this->token) { + $headers['X-Vault-Token'] = $this->token; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/{$this->apiVersion}/{$path}", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error reading secret: '.implode(', ', $data['errors']); + } + + return [ + 'data' => $data['data']['data'] ?? $data['data'] ?? [], + 'metadata' => [ + 'created_time' => $data['data']['metadata']['created_time'] ?? '', + 'custom_metadata' => $data['data']['metadata']['custom_metadata'] ?? [], + 'deletion_time' => $data['data']['metadata']['deletion_time'] ?? '', + 'destroyed' => $data['data']['metadata']['destroyed'] ?? false, + 'version' => $data['data']['metadata']['version'] ?? 1, + ], + 'lease_duration' => $data['lease_duration'] ?? 0, + 'lease_id' => $data['lease_id'] ?? '', + 'renewable' => $data['renewable'] ?? false, + 'request_id' => $data['request_id'] ?? '', + 'warnings' => $data['warnings'] ?? [], + 'wrap_info' => $data['wrap_info'] ?? null, + ]; + } catch (\Exception $e) { + return 'Error reading secret: '.$e->getMessage(); + } + } + + /** + * Write secret to Vault. + * + * @param string $path Secret path + * @param array $data Secret data + * @param array $customMetadata Custom metadata + * + * @return array{ + * success: bool, + * version: int, + * created_time: string, + * request_id: string, + * error: string, + * }|string + */ + public function writeSecret( + string $path, + array $data = [], + array $customMetadata = [], + ): array|string { + try { + $body = [ + 'data' => $data, + ]; + + if (!empty($customMetadata)) { + $body['options'] = [ + 'cas' => 0, + ]; + $body['metadata'] = $customMetadata; + } + + $headers = [ + 'Content-Type' => 'application/json', + 'X-Vault-Request' => 'true', + ]; + + if ($this->token) { + $headers['X-Vault-Token'] = $this->token; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/{$this->apiVersion}/{$path}", [ + 'headers' => $headers, + 'json' => $body, + ]); + + $responseData = $response->toArray(); + + if (isset($responseData['errors'])) { + return [ + 'success' => false, + 'version' => 0, + 'created_time' => '', + 'request_id' => '', + 'error' => implode(', ', $responseData['errors']), + ]; + } + + return [ + 'success' => true, + 'version' => $responseData['data']['version'] ?? 1, + 'created_time' => $responseData['data']['created_time'] ?? '', + 'request_id' => $responseData['request_id'] ?? '', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'version' => 0, + 'created_time' => '', + 'request_id' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Delete secret from Vault. + * + * @param string $path Secret path + * @param string $versions Versions to delete (comma-separated) + * + * @return array{ + * success: bool, + * request_id: string, + * error: string, + * }|string + */ + public function deleteSecret( + string $path, + string $versions = '', + ): array|string { + try { + $headers = [ + 'Content-Type' => 'application/json', + 'X-Vault-Request' => 'true', + ]; + + if ($this->token) { + $headers['X-Vault-Token'] = $this->token; + } + + $body = []; + if ($versions) { + $body['versions'] = array_map('intval', explode(',', $versions)); + } + + $method = $versions ? 'POST' : 'DELETE'; + $url = $versions ? "{$this->baseUrl}/{$this->apiVersion}/{$path}/delete" : "{$this->baseUrl}/{$this->apiVersion}/{$path}"; + + $response = $this->httpClient->request($method, $url, [ + 'headers' => $headers, + 'json' => $body, + ]); + + $responseData = $response->toArray(); + + if (isset($responseData['errors'])) { + return [ + 'success' => false, + 'request_id' => '', + 'error' => implode(', ', $responseData['errors']), + ]; + } + + return [ + 'success' => true, + 'request_id' => $responseData['request_id'] ?? '', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'request_id' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * List secrets in Vault. + * + * @param string $path Secret path + * + * @return array{ + * keys: array, + * request_id: string, + * }|string + */ + public function listSecrets(string $path): array|string + { + try { + $headers = [ + 'Content-Type' => 'application/json', + 'X-Vault-Request' => 'true', + ]; + + if ($this->token) { + $headers['X-Vault-Token'] = $this->token; + } + + $response = $this->httpClient->request('LIST', "{$this->baseUrl}/{$this->apiVersion}/{$path}", [ + 'headers' => $headers, + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return 'Error listing secrets: '.implode(', ', $data['errors']); + } + + return [ + 'keys' => $data['data']['keys'] ?? [], + 'request_id' => $data['request_id'] ?? '', + ]; + } catch (\Exception $e) { + return 'Error listing secrets: '.$e->getMessage(); + } + } + + /** + * Authenticate with Vault using token. + * + * @param string $token Vault token + * + * @return array{ + * success: bool, + * client_token: string, + * accessor: string, + * policies: array, + * token_policies: array, + * metadata: array, + * lease_duration: int, + * renewable: bool, + * entity_id: string, + * token_type: string, + * orphan: bool, + * error: string, + * }|string + */ + public function authToken(string $token): array|string + { + try { + $headers = [ + 'Content-Type' => 'application/json', + 'X-Vault-Token' => $token, + ]; + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/{$this->apiVersion}/auth/token/lookup-self", [ + 'headers' => $headers, + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return [ + 'success' => false, + 'client_token' => '', + 'accessor' => '', + 'policies' => [], + 'token_policies' => [], + 'metadata' => [], + 'lease_duration' => 0, + 'renewable' => false, + 'entity_id' => '', + 'token_type' => '', + 'orphan' => false, + 'error' => implode(', ', $data['errors']), + ]; + } + + return [ + 'success' => true, + 'client_token' => $data['data']['id'] ?? '', + 'accessor' => $data['data']['accessor'] ?? '', + 'policies' => $data['data']['policies'] ?? [], + 'token_policies' => $data['data']['token_policies'] ?? [], + 'metadata' => $data['data']['meta'] ?? [], + 'lease_duration' => $data['data']['ttl'] ?? 0, + 'renewable' => $data['data']['renewable'] ?? false, + 'entity_id' => $data['data']['entity_id'] ?? '', + 'token_type' => $data['data']['type'] ?? '', + 'orphan' => $data['data']['orphan'] ?? false, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'client_token' => '', + 'accessor' => '', + 'policies' => [], + 'token_policies' => [], + 'metadata' => [], + 'lease_duration' => 0, + 'renewable' => false, + 'entity_id' => '', + 'token_type' => '', + 'orphan' => false, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Authenticate with Vault using username/password. + * + * @param string $username Username + * @param string $password Password + * @param string $mountPath Auth mount path + * + * @return array{ + * success: bool, + * client_token: string, + * accessor: string, + * policies: array, + * token_policies: array, + * metadata: array, + * lease_duration: int, + * renewable: bool, + * entity_id: string, + * token_type: string, + * orphan: bool, + * error: string, + * }|string + */ + public function authUserpass( + string $username, + string $password, + string $mountPath = 'userpass', + ): array|string { + try { + $body = [ + 'password' => $password, + ]; + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/{$this->apiVersion}/auth/{$mountPath}/login/{$username}", [ + 'headers' => $headers, + 'json' => $body, + ]); + + $data = $response->toArray(); + + if (isset($data['errors'])) { + return [ + 'success' => false, + 'client_token' => '', + 'accessor' => '', + 'policies' => [], + 'token_policies' => [], + 'metadata' => [], + 'lease_duration' => 0, + 'renewable' => false, + 'entity_id' => '', + 'token_type' => '', + 'orphan' => false, + 'error' => implode(', ', $data['errors']), + ]; + } + + return [ + 'success' => true, + 'client_token' => $data['auth']['client_token'] ?? '', + 'accessor' => $data['auth']['accessor'] ?? '', + 'policies' => $data['auth']['policies'] ?? [], + 'token_policies' => $data['auth']['token_policies'] ?? [], + 'metadata' => $data['auth']['metadata'] ?? [], + 'lease_duration' => $data['auth']['lease_duration'] ?? 0, + 'renewable' => $data['auth']['renewable'] ?? false, + 'entity_id' => $data['auth']['entity_id'] ?? '', + 'token_type' => $data['auth']['token_type'] ?? '', + 'orphan' => $data['auth']['orphan'] ?? false, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'client_token' => '', + 'accessor' => '', + 'policies' => [], + 'token_policies' => [], + 'metadata' => [], + 'lease_duration' => 0, + 'renewable' => false, + 'entity_id' => '', + 'token_type' => '', + 'orphan' => false, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/VectorStore.php b/src/agent/src/Toolbox/Tool/VectorStore.php new file mode 100644 index 000000000..5bec5b17f --- /dev/null +++ b/src/agent/src/Toolbox/Tool/VectorStore.php @@ -0,0 +1,624 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('vectorstore_create_collection', 'Tool that creates vector store collections')] +#[AsTool('vectorstore_add_documents', 'Tool that adds documents to vector store', method: 'addDocuments')] +#[AsTool('vectorstore_search_similar', 'Tool that searches for similar documents', method: 'searchSimilar')] +#[AsTool('vectorstore_delete_documents', 'Tool that deletes documents from vector store', method: 'deleteDocuments')] +#[AsTool('vectorstore_list_collections', 'Tool that lists vector store collections', method: 'listCollections')] +#[AsTool('vectorstore_get_collection_info', 'Tool that gets collection information', method: 'getCollectionInfo')] +#[AsTool('vectorstore_update_documents', 'Tool that updates documents in vector store', method: 'updateDocuments')] +#[AsTool('vectorstore_get_document', 'Tool that gets a specific document', method: 'getDocument')] +final readonly class VectorStore +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey = '', + private string $baseUrl = 'https://api.pinecone.io', + private array $options = [], + ) { + } + + /** + * Create vector store collection. + * + * @param string $name Collection name + * @param int $dimension Vector dimension + * @param string $metric Distance metric (cosine, euclidean, dotproduct) + * @param array $metadata Collection metadata + * @param string $environment Pinecone environment + * + * @return array{ + * success: bool, + * collection: array{ + * name: string, + * dimension: int, + * metric: string, + * status: string, + * indexCount: int, + * vectorCount: int, + * recordCount: int, + * metadata: array, + * createdAt: string, + * updatedAt: string, + * }, + * error: string, + * } + */ + public function __invoke( + string $name, + int $dimension, + string $metric = 'cosine', + array $metadata = [], + string $environment = 'us-east-1', + ): array { + try { + $requestData = [ + 'name' => $name, + 'dimension' => $dimension, + 'metric' => $metric, + 'metadata' => $metadata, + ]; + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['Api-Key'] = $this->apiKey; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/collections", [ + 'headers' => $headers, + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'collection' => [ + 'name' => $data['name'] ?? $name, + 'dimension' => $data['dimension'] ?? $dimension, + 'metric' => $data['metric'] ?? $metric, + 'status' => $data['status'] ?? 'initializing', + 'indexCount' => $data['index_count'] ?? 0, + 'vectorCount' => $data['vector_count'] ?? 0, + 'recordCount' => $data['record_count'] ?? 0, + 'metadata' => $data['metadata'] ?? $metadata, + 'createdAt' => $data['created_at'] ?? date('c'), + 'updatedAt' => $data['updated_at'] ?? date('c'), + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'collection' => [ + 'name' => $name, + 'dimension' => $dimension, + 'metric' => $metric, + 'status' => 'error', + 'indexCount' => 0, + 'vectorCount' => 0, + 'recordCount' => 0, + 'metadata' => $metadata, + 'createdAt' => '', + 'updatedAt' => '', + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Add documents to vector store. + * + * @param string $collectionName Collection name + * @param array, + * metadata: array, + * }> $vectors Document vectors + * @param string $namespace Namespace (optional) + * + * @return array{ + * success: bool, + * upsertedCount: int, + * message: string, + * error: string, + * } + */ + public function addDocuments( + string $collectionName, + array $vectors, + string $namespace = '', + ): array { + try { + $requestData = [ + 'vectors' => $vectors, + ]; + + if ($namespace) { + $requestData['namespace'] = $namespace; + } + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['Api-Key'] = $this->apiKey; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/collections/{$collectionName}/vectors/upsert", [ + 'headers' => $headers, + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'upsertedCount' => $data['upserted_count'] ?? \count($vectors), + 'message' => 'Documents added successfully', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'upsertedCount' => 0, + 'message' => 'Failed to add documents', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Search for similar documents. + * + * @param string $collectionName Collection name + * @param array $queryVector Query vector + * @param int $topK Number of results + * @param string $namespace Namespace (optional) + * @param array $filter Filter criteria + * @param bool $includeMetadata Include metadata in results + * @param bool $includeValues Include values in results + * + * @return array{ + * success: bool, + * matches: array, + * metadata: array, + * }>, + * namespace: string, + * usage: array{ + * readUnits: int, + * }, + * error: string, + * } + */ + public function searchSimilar( + string $collectionName, + array $queryVector, + int $topK = 10, + string $namespace = '', + array $filter = [], + bool $includeMetadata = true, + bool $includeValues = false, + ): array { + try { + $requestData = [ + 'vector' => $queryVector, + 'topK' => max(1, min($topK, 10000)), + 'includeMetadata' => $includeMetadata, + 'includeValues' => $includeValues, + ]; + + if ($namespace) { + $requestData['namespace'] = $namespace; + } + + if (!empty($filter)) { + $requestData['filter'] = $filter; + } + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['Api-Key'] = $this->apiKey; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/collections/{$collectionName}/query", [ + 'headers' => $headers, + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'matches' => array_map(fn ($match) => [ + 'id' => $match['id'] ?? '', + 'score' => $match['score'] ?? 0.0, + 'values' => $match['values'] ?? [], + 'metadata' => $match['metadata'] ?? [], + ], $data['matches'] ?? []), + 'namespace' => $data['namespace'] ?? $namespace, + 'usage' => [ + 'readUnits' => $data['usage']['readUnits'] ?? 0, + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'matches' => [], + 'namespace' => $namespace, + 'usage' => ['readUnits' => 0], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Delete documents from vector store. + * + * @param string $collectionName Collection name + * @param array $ids Document IDs to delete + * @param string $namespace Namespace (optional) + * @param array $filter Filter criteria + * @param bool $deleteAll Delete all vectors + * + * @return array{ + * success: bool, + * deletedCount: int, + * message: string, + * error: string, + * } + */ + public function deleteDocuments( + string $collectionName, + array $ids = [], + string $namespace = '', + array $filter = [], + bool $deleteAll = false, + ): array { + try { + $requestData = []; + + if ($deleteAll) { + $requestData['deleteAll'] = true; + } elseif (!empty($ids)) { + $requestData['ids'] = $ids; + } elseif (!empty($filter)) { + $requestData['filter'] = $filter; + } + + if ($namespace) { + $requestData['namespace'] = $namespace; + } + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['Api-Key'] = $this->apiKey; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/collections/{$collectionName}/vectors/delete", [ + 'headers' => $headers, + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'deletedCount' => $data['deleted_count'] ?? \count($ids), + 'message' => 'Documents deleted successfully', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'deletedCount' => 0, + 'message' => 'Failed to delete documents', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * List vector store collections. + * + * @return array{ + * success: bool, + * collections: array, + * createdAt: string, + * updatedAt: string, + * }>, + * error: string, + * } + */ + public function listCollections(): array + { + try { + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['Api-Key'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/collections", [ + 'headers' => $headers, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'collections' => array_map(fn ($collection) => [ + 'name' => $collection['name'] ?? '', + 'dimension' => $collection['dimension'] ?? 0, + 'metric' => $collection['metric'] ?? '', + 'status' => $collection['status'] ?? '', + 'indexCount' => $collection['index_count'] ?? 0, + 'vectorCount' => $collection['vector_count'] ?? 0, + 'recordCount' => $collection['record_count'] ?? 0, + 'metadata' => $collection['metadata'] ?? [], + 'createdAt' => $collection['created_at'] ?? '', + 'updatedAt' => $collection['updated_at'] ?? '', + ], $data['collections'] ?? []), + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'collections' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get collection information. + * + * @param string $collectionName Collection name + * + * @return array{ + * success: bool, + * collection: array{ + * name: string, + * dimension: int, + * metric: string, + * status: string, + * indexCount: int, + * vectorCount: int, + * recordCount: int, + * metadata: array, + * createdAt: string, + * updatedAt: string, + * }, + * error: string, + * } + */ + public function getCollectionInfo(string $collectionName): array + { + try { + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['Api-Key'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/collections/{$collectionName}", [ + 'headers' => $headers, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'collection' => [ + 'name' => $data['name'] ?? $collectionName, + 'dimension' => $data['dimension'] ?? 0, + 'metric' => $data['metric'] ?? '', + 'status' => $data['status'] ?? '', + 'indexCount' => $data['index_count'] ?? 0, + 'vectorCount' => $data['vector_count'] ?? 0, + 'recordCount' => $data['record_count'] ?? 0, + 'metadata' => $data['metadata'] ?? [], + 'createdAt' => $data['created_at'] ?? '', + 'updatedAt' => $data['updated_at'] ?? '', + ], + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'collection' => [ + 'name' => $collectionName, + 'dimension' => 0, + 'metric' => '', + 'status' => 'error', + 'indexCount' => 0, + 'vectorCount' => 0, + 'recordCount' => 0, + 'metadata' => [], + 'createdAt' => '', + 'updatedAt' => '', + ], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Update documents in vector store. + * + * @param string $collectionName Collection name + * @param array, + * metadata: array, + * }> $vectors Document vectors + * @param string $namespace Namespace (optional) + * + * @return array{ + * success: bool, + * upsertedCount: int, + * message: string, + * error: string, + * } + */ + public function updateDocuments( + string $collectionName, + array $vectors, + string $namespace = '', + ): array { + try { + $requestData = [ + 'vectors' => $vectors, + ]; + + if ($namespace) { + $requestData['namespace'] = $namespace; + } + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['Api-Key'] = $this->apiKey; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/collections/{$collectionName}/vectors/upsert", [ + 'headers' => $headers, + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'upsertedCount' => $data['upserted_count'] ?? \count($vectors), + 'message' => 'Documents updated successfully', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'upsertedCount' => 0, + 'message' => 'Failed to update documents', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Get a specific document. + * + * @param string $collectionName Collection name + * @param string $id Document ID + * @param string $namespace Namespace (optional) + * + * @return array{ + * success: bool, + * document: array{ + * id: string, + * values: array, + * metadata: array, + * }, + * namespace: string, + * error: string, + * } + */ + public function getDocument( + string $collectionName, + string $id, + string $namespace = '', + ): array { + try { + $params = []; + + if ($namespace) { + $params['namespace'] = $namespace; + } + + $headers = [ + 'Content-Type' => 'application/json', + ]; + + if ($this->apiKey) { + $headers['Api-Key'] = $this->apiKey; + } + + $response = $this->httpClient->request('GET', "{$this->baseUrl}/collections/{$collectionName}/vectors/{$id}", [ + 'headers' => $headers, + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'success' => true, + 'document' => [ + 'id' => $data['id'] ?? $id, + 'values' => $data['values'] ?? [], + 'metadata' => $data['metadata'] ?? [], + ], + 'namespace' => $data['namespace'] ?? $namespace, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'document' => [ + 'id' => $id, + 'values' => [], + 'metadata' => [], + ], + 'namespace' => $namespace, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Vercel.php b/src/agent/src/Toolbox/Tool/Vercel.php new file mode 100644 index 000000000..244b4e924 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Vercel.php @@ -0,0 +1,676 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('vercel_get_deployments', 'Tool that gets Vercel deployments')] +#[AsTool('vercel_create_deployment', 'Tool that creates Vercel deployments', method: 'createDeployment')] +#[AsTool('vercel_get_projects', 'Tool that gets Vercel projects', method: 'getProjects')] +#[AsTool('vercel_create_project', 'Tool that creates Vercel projects', method: 'createProject')] +#[AsTool('vercel_get_domains', 'Tool that gets Vercel domains', method: 'getDomains')] +#[AsTool('vercel_get_team_members', 'Tool that gets Vercel team members', method: 'getTeamMembers')] +final readonly class Vercel +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private string $apiVersion = 'v9', + private array $options = [], + ) { + } + + /** + * Get Vercel deployments. + * + * @param string $projectId Project ID to filter deployments + * @param string $teamId Team ID + * @param int $limit Number of deployments to retrieve + * @param string $since Since timestamp + * @param string $until Until timestamp + * @param string $state Deployment state (BUILDING, ERROR, INITIALIZING, QUEUED, READY, CANCELED) + * @param string $target Deployment target (production, preview) + * + * @return array, + * target: string, + * aliasAssigned: bool, + * alias: array, + * aliasError: array|null, + * aliasWarning: array|null, + * gitSource: array{ + * type: string, + * repo: string, + * ref: string, + * sha: string, + * repoId: int, + * org: string, + * }|null, + * projectId: string, + * teamId: string, + * build: array{env: array}, + * functions: array|null, + * plan: string, + * regions: array, + * readyState: string, + * readySubstate: string|null, + * checksState: string, + * checksConclusion: string, + * framework: string|null, + * gitCommitRef: string|null, + * gitCommitSha: string|null, + * gitCommitMessage: string|null, + * gitCommitAuthorLogin: string|null, + * gitCommitAuthorName: string|null, + * }> + */ + public function __invoke( + string $projectId = '', + string $teamId = '', + int $limit = 20, + string $since = '', + string $until = '', + string $state = '', + string $target = '', + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), + ]; + + if ($projectId) { + $params['projectId'] = $projectId; + } + if ($teamId) { + $params['teamId'] = $teamId; + } + if ($since) { + $params['since'] = $since; + } + if ($until) { + $params['until'] = $until; + } + if ($state) { + $params['state'] = $state; + } + if ($target) { + $params['target'] = $target; + } + + $response = $this->httpClient->request('GET', "https://api.vercel.com/{$this->apiVersion}/deployments", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($deployment) => [ + 'uid' => $deployment['uid'], + 'name' => $deployment['name'], + 'url' => $deployment['url'], + 'created' => $deployment['created'], + 'source' => $deployment['source'], + 'state' => $deployment['state'], + 'type' => $deployment['type'], + 'creator' => [ + 'uid' => $deployment['creator']['uid'], + 'username' => $deployment['creator']['username'], + 'email' => $deployment['creator']['email'], + ], + 'inspectorUrl' => $deployment['inspectorUrl'], + 'meta' => $deployment['meta'] ?? [], + 'target' => $deployment['target'], + 'aliasAssigned' => $deployment['aliasAssigned'] ?? false, + 'alias' => $deployment['alias'] ?? [], + 'aliasError' => $deployment['aliasError'], + 'aliasWarning' => $deployment['aliasWarning'], + 'gitSource' => $deployment['gitSource'] ? [ + 'type' => $deployment['gitSource']['type'], + 'repo' => $deployment['gitSource']['repo'], + 'ref' => $deployment['gitSource']['ref'], + 'sha' => $deployment['gitSource']['sha'], + 'repoId' => $deployment['gitSource']['repoId'], + 'org' => $deployment['gitSource']['org'], + ] : null, + 'projectId' => $deployment['projectId'], + 'teamId' => $deployment['teamId'], + 'build' => [ + 'env' => $deployment['build']['env'] ?? [], + ], + 'functions' => $deployment['functions'], + 'plan' => $deployment['plan'] ?? 'hobby', + 'regions' => $deployment['regions'] ?? [], + 'readyState' => $deployment['readyState'] ?? 'READY', + 'readySubstate' => $deployment['readySubstate'], + 'checksState' => $deployment['checksState'] ?? 'REGISTERED', + 'checksConclusion' => $deployment['checksConclusion'] ?? 'PENDING', + 'framework' => $deployment['framework'], + 'gitCommitRef' => $deployment['gitCommitRef'], + 'gitCommitSha' => $deployment['gitCommitSha'], + 'gitCommitMessage' => $deployment['gitCommitMessage'], + 'gitCommitAuthorLogin' => $deployment['gitCommitAuthorLogin'], + 'gitCommitAuthorName' => $deployment['gitCommitAuthorName'], + ], $data['deployments'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a Vercel deployment. + * + * @param string $name Deployment name + * @param string $gitSource Git source URL + * @param string $target Deployment target (production, preview) + * @param array $env Environment variables + * @param string $projectId Project ID + * @param string $teamId Team ID + * @param bool $withCache Use cache + * + * @return array{ + * uid: string, + * name: string, + * url: string, + * created: int, + * source: string, + * state: string, + * type: string, + * creator: array{uid: string, username: string, email: string}, + * inspectorUrl: string, + * target: string, + * projectId: string, + * teamId: string, + * build: array{env: array}, + * plan: string, + * readyState: string, + * framework: string|null, + * }|string + */ + public function createDeployment( + string $name, + string $gitSource, + string $target = 'preview', + array $env = [], + string $projectId = '', + string $teamId = '', + bool $withCache = true, + ): array|string { + try { + $payload = [ + 'name' => $name, + 'gitSource' => [ + 'type' => 'github', + 'repo' => $gitSource, + 'ref' => 'main', + ], + 'target' => $target, + 'env' => $env, + 'withCache' => $withCache, + ]; + + if ($projectId) { + $payload['projectId'] = $projectId; + } + if ($teamId) { + $payload['teamId'] = $teamId; + } + + $response = $this->httpClient->request('POST', "https://api.vercel.com/{$this->apiVersion}/deployments", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating deployment: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'uid' => $data['uid'], + 'name' => $data['name'], + 'url' => $data['url'], + 'created' => $data['created'], + 'source' => $data['source'], + 'state' => $data['state'], + 'type' => $data['type'], + 'creator' => [ + 'uid' => $data['creator']['uid'], + 'username' => $data['creator']['username'], + 'email' => $data['creator']['email'], + ], + 'inspectorUrl' => $data['inspectorUrl'], + 'target' => $data['target'], + 'projectId' => $data['projectId'], + 'teamId' => $data['teamId'], + 'build' => [ + 'env' => $data['build']['env'] ?? [], + ], + 'plan' => $data['plan'] ?? 'hobby', + 'readyState' => $data['readyState'] ?? 'READY', + 'framework' => $data['framework'], + ]; + } catch (\Exception $e) { + return 'Error creating deployment: '.$e->getMessage(); + } + } + + /** + * Get Vercel projects. + * + * @param string $teamId Team ID + * @param int $limit Number of projects to retrieve + * @param string $search Search term + * + * @return array, + * latestDeployments: array, + * targets: array, + * link: array{type: string, repo: string, repoId: int}|null, + * latestDeploymentStatus: string, + * gitRepository: array{type: string, repo: string, repoId: int, org: string, path: string}|null, + * framework: string|null, + * nodeVersion: string, + * installCommand: string|null, + * buildCommand: string|null, + * outputDirectory: string|null, + * publicSource: bool, + * rootDirectory: string|null, + * }> + */ + public function getProjects( + string $teamId = '', + int $limit = 20, + string $search = '', + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), + ]; + + if ($teamId) { + $params['teamId'] = $teamId; + } + if ($search) { + $params['search'] = $search; + } + + $response = $this->httpClient->request('GET', "https://api.vercel.com/{$this->apiVersion}/projects", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($project) => [ + 'id' => $project['id'], + 'name' => $project['name'], + 'accountId' => $project['accountId'], + 'createdAt' => $project['createdAt'], + 'updatedAt' => $project['updatedAt'], + 'alias' => $project['alias'] ?? [], + 'latestDeployments' => array_map(fn ($deployment) => [ + 'uid' => $deployment['uid'], + 'name' => $deployment['name'], + 'url' => $deployment['url'], + 'created' => $deployment['created'], + 'state' => $deployment['state'], + 'type' => $deployment['type'], + 'target' => $deployment['target'], + 'inspectorUrl' => $deployment['inspectorUrl'], + ], $project['latestDeployments'] ?? []), + 'targets' => $project['targets'] ?? [], + 'link' => $project['link'] ? [ + 'type' => $project['link']['type'], + 'repo' => $project['link']['repo'], + 'repoId' => $project['link']['repoId'], + ] : null, + 'latestDeploymentStatus' => $project['latestDeploymentStatus'] ?? 'READY', + 'gitRepository' => $project['gitRepository'] ? [ + 'type' => $project['gitRepository']['type'], + 'repo' => $project['gitRepository']['repo'], + 'repoId' => $project['gitRepository']['repoId'], + 'org' => $project['gitRepository']['org'], + 'path' => $project['gitRepository']['path'], + ] : null, + 'framework' => $project['framework'], + 'nodeVersion' => $project['nodeVersion'] ?? '18.x', + 'installCommand' => $project['installCommand'], + 'buildCommand' => $project['buildCommand'], + 'outputDirectory' => $project['outputDirectory'], + 'publicSource' => $project['publicSource'] ?? false, + 'rootDirectory' => $project['rootDirectory'], + ], $data['projects'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a Vercel project. + * + * @param string $name Project name + * @param string $gitRepository Git repository URL + * @param string $framework Framework (nextjs, nuxtjs, svelte, vue, etc.) + * @param string $teamId Team ID + * @param string $rootDirectory Root directory + * @param string $installCommand Install command + * @param string $buildCommand Build command + * @param string $outputDirectory Output directory + * @param string $nodeVersion Node.js version + * + * @return array{ + * id: string, + * name: string, + * accountId: string, + * createdAt: int, + * updatedAt: int, + * alias: array, + * targets: array, + * link: array{type: string, repo: string, repoId: int}, + * latestDeploymentStatus: string, + * gitRepository: array{type: string, repo: string, repoId: int, org: string, path: string}, + * framework: string, + * nodeVersion: string, + * installCommand: string|null, + * buildCommand: string|null, + * outputDirectory: string|null, + * publicSource: bool, + * rootDirectory: string|null, + * }|string + */ + public function createProject( + string $name, + string $gitRepository, + string $framework = 'nextjs', + string $teamId = '', + string $rootDirectory = '', + string $installCommand = '', + string $buildCommand = '', + string $outputDirectory = '', + string $nodeVersion = '18.x', + ): array|string { + try { + $payload = [ + 'name' => $name, + 'gitRepository' => [ + 'type' => 'github', + 'repo' => $gitRepository, + ], + 'framework' => $framework, + 'nodeVersion' => $nodeVersion, + 'publicSource' => false, + ]; + + if ($teamId) { + $payload['teamId'] = $teamId; + } + if ($rootDirectory) { + $payload['rootDirectory'] = $rootDirectory; + } + if ($installCommand) { + $payload['installCommand'] = $installCommand; + } + if ($buildCommand) { + $payload['buildCommand'] = $buildCommand; + } + if ($outputDirectory) { + $payload['outputDirectory'] = $outputDirectory; + } + + $response = $this->httpClient->request('POST', "https://api.vercel.com/{$this->apiVersion}/projects", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error creating project: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'accountId' => $data['accountId'], + 'createdAt' => $data['createdAt'], + 'updatedAt' => $data['updatedAt'], + 'alias' => $data['alias'] ?? [], + 'targets' => $data['targets'] ?? [], + 'link' => [ + 'type' => $data['link']['type'], + 'repo' => $data['link']['repo'], + 'repoId' => $data['link']['repoId'], + ], + 'latestDeploymentStatus' => $data['latestDeploymentStatus'] ?? 'READY', + 'gitRepository' => [ + 'type' => $data['gitRepository']['type'], + 'repo' => $data['gitRepository']['repo'], + 'repoId' => $data['gitRepository']['repoId'], + 'org' => $data['gitRepository']['org'], + 'path' => $data['gitRepository']['path'], + ], + 'framework' => $data['framework'], + 'nodeVersion' => $data['nodeVersion'], + 'installCommand' => $data['installCommand'], + 'buildCommand' => $data['buildCommand'], + 'outputDirectory' => $data['outputDirectory'], + 'publicSource' => $data['publicSource'] ?? false, + 'rootDirectory' => $data['rootDirectory'], + ]; + } catch (\Exception $e) { + return 'Error creating project: '.$e->getMessage(); + } + } + + /** + * Get Vercel domains. + * + * @param string $teamId Team ID + * @param int $limit Number of domains to retrieve + * + * @return array, + * nameservers: array, + * creator: array{id: string, email: string, username: string}, + * createdAt: int, + * updatedAt: int, + * expiresAt: int|null, + * boughtAt: int|null, + * transferredAt: int|null, + * orderedAt: int|null, + * renewalPrice: int|null, + * sslStatus: string|null, + * aliases: array, + * projectId: string|null, + * target: string|null, + * redirect: string|null, + * mxRecords: array, + * txtRecords: array, + * aRecords: array, + * aaaaRecords: array, + * cnameRecords: array, + * }> + */ + public function getDomains( + string $teamId = '', + int $limit = 20, + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), + ]; + + if ($teamId) { + $params['teamId'] = $teamId; + } + + $response = $this->httpClient->request('GET', "https://api.vercel.com/{$this->apiVersion}/domains", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($domain) => [ + 'id' => $domain['id'], + 'name' => $domain['name'], + 'serviceType' => $domain['serviceType'], + 'verified' => $domain['verified'] ?? false, + 'cname' => $domain['cname'] ?? '', + 'intendedNameservers' => $domain['intendedNameservers'] ?? [], + 'nameservers' => $domain['nameservers'] ?? [], + 'creator' => [ + 'id' => $domain['creator']['id'], + 'email' => $domain['creator']['email'], + 'username' => $domain['creator']['username'], + ], + 'createdAt' => $domain['createdAt'], + 'updatedAt' => $domain['updatedAt'], + 'expiresAt' => $domain['expiresAt'], + 'boughtAt' => $domain['boughtAt'], + 'transferredAt' => $domain['transferredAt'], + 'orderedAt' => $domain['orderedAt'], + 'renewalPrice' => $domain['renewalPrice'], + 'sslStatus' => $domain['sslStatus'], + 'aliases' => $domain['aliases'] ?? [], + 'projectId' => $domain['projectId'], + 'target' => $domain['target'], + 'redirect' => $domain['redirect'], + 'mxRecords' => $domain['mxRecords'] ?? [], + 'txtRecords' => $domain['txtRecords'] ?? [], + 'aRecords' => $domain['aRecords'] ?? [], + 'aaaaRecords' => $domain['aaaaRecords'] ?? [], + 'cnameRecords' => $domain['cnameRecords'] ?? [], + ], $data['domains'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Vercel team members. + * + * @param string $teamId Team ID + * @param int $limit Number of members to retrieve + * + * @return array + */ + public function getTeamMembers( + string $teamId, + int $limit = 20, + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), + ]; + + $response = $this->httpClient->request('GET', "https://api.vercel.com/{$this->apiVersion}/teams/{$teamId}/members", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($member) => [ + 'uid' => $member['uid'], + 'email' => $member['email'], + 'username' => $member['username'], + 'name' => $member['name'], + 'avatar' => $member['avatar'], + 'role' => $member['role'], + 'billingRole' => $member['billingRole'], + 'joinedAt' => $member['joinedAt'], + 'limited' => $member['limited'] ?? false, + 'restricted' => $member['restricted'] ?? false, + ], $data['members'] ?? []); + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/WhatsApp.php b/src/agent/src/Toolbox/Tool/WhatsApp.php new file mode 100644 index 000000000..4d9f8c68e --- /dev/null +++ b/src/agent/src/Toolbox/Tool/WhatsApp.php @@ -0,0 +1,495 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('whatsapp_send_message', 'Tool that sends messages via WhatsApp Business API')] +#[AsTool('whatsapp_send_template', 'Tool that sends template messages via WhatsApp', method: 'sendTemplate')] +#[AsTool('whatsapp_send_media', 'Tool that sends media via WhatsApp', method: 'sendMedia')] +#[AsTool('whatsapp_get_webhook_events', 'Tool that gets WhatsApp webhook events', method: 'getWebhookEvents')] +#[AsTool('whatsapp_mark_as_read', 'Tool that marks messages as read', method: 'markAsRead')] +final readonly class WhatsApp +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private string $phoneNumberId, + private string $businessAccountId, + private array $options = [], + ) { + } + + /** + * Send a message via WhatsApp Business API. + * + * @param string $to Recipient phone number (with country code, no +) + * @param string $message Message text content + * @param string $type Message type (text, interactive, button, list) + * @param array $context Optional context for replies + * + * @return array{ + * messaging_product: string, + * contacts: array, + * messages: array, + * }|string + */ + public function __invoke( + string $to, + #[With(maximum: 4096)] + string $message, + string $type = 'text', + array $context = [], + ): array|string { + try { + $payload = [ + 'messaging_product' => 'whatsapp', + 'to' => $to, + 'type' => $type, + 'text' => [ + 'body' => $message, + ], + ]; + + if (!empty($context)) { + $payload['context'] = $context; + } + + $response = $this->httpClient->request('POST', "https://graph.facebook.com/v18.0/{$this->phoneNumberId}/messages", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error sending message: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'messaging_product' => $data['messaging_product'], + 'contacts' => $data['contacts'] ?? [], + 'messages' => $data['messages'] ?? [], + ]; + } catch (\Exception $e) { + return 'Error sending message: '.$e->getMessage(); + } + } + + /** + * Send a template message via WhatsApp. + * + * @param string $to Recipient phone number + * @param string $templateName Template name + * @param string $language Template language code + * @param array $components Template components + * + * @return array{ + * messaging_product: string, + * contacts: array, + * messages: array, + * }|string + */ + public function sendTemplate( + string $to, + string $templateName, + string $language = 'en_US', + array $components = [], + ): array|string { + try { + $payload = [ + 'messaging_product' => 'whatsapp', + 'to' => $to, + 'type' => 'template', + 'template' => [ + 'name' => $templateName, + 'language' => [ + 'code' => $language, + ], + ], + ]; + + if (!empty($components)) { + $payload['template']['components'] = $components; + } + + $response = $this->httpClient->request('POST', "https://graph.facebook.com/v18.0/{$this->phoneNumberId}/messages", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error sending template: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'messaging_product' => $data['messaging_product'], + 'contacts' => $data['contacts'] ?? [], + 'messages' => $data['messages'] ?? [], + ]; + } catch (\Exception $e) { + return 'Error sending template: '.$e->getMessage(); + } + } + + /** + * Send media via WhatsApp. + * + * @param string $to Recipient phone number + * @param string $mediaType Media type (image, video, audio, document) + * @param string $mediaUrl URL of the media file + * @param string $caption Optional caption for the media + * @param string $filename Optional filename for documents + * + * @return array{ + * messaging_product: string, + * contacts: array, + * messages: array, + * }|string + */ + public function sendMedia( + string $to, + string $mediaType, + string $mediaUrl, + string $caption = '', + string $filename = '', + ): array|string { + try { + $payload = [ + 'messaging_product' => 'whatsapp', + 'to' => $to, + 'type' => $mediaType, + $mediaType => [ + 'link' => $mediaUrl, + ], + ]; + + if ($caption) { + $payload[$mediaType]['caption'] = $caption; + } + + if ($filename && 'document' === $mediaType) { + $payload[$mediaType]['filename'] = $filename; + } + + $response = $this->httpClient->request('POST', "https://graph.facebook.com/v18.0/{$this->phoneNumberId}/messages", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error sending media: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'messaging_product' => $data['messaging_product'], + 'contacts' => $data['contacts'] ?? [], + 'messages' => $data['messages'] ?? [], + ]; + } catch (\Exception $e) { + return 'Error sending media: '.$e->getMessage(); + } + } + + /** + * Get WhatsApp webhook events (simulated - in real implementation, this would be handled by webhooks). + * + * @param int $limit Maximum number of events to retrieve + * @param string $since Get events since this timestamp + * + * @return array + */ + public function getWebhookEvents(int $limit = 50, string $since = ''): array + { + try { + // Note: In a real implementation, webhook events are received via HTTP POST + // This is a placeholder method for demonstration purposes + // You would typically store webhook events in a database and retrieve them here + + $params = [ + 'limit' => min(max($limit, 1), 100), + ]; + + if ($since) { + $params['since'] = $since; + } + + // This would typically query your webhook event storage + // For now, returning empty array as placeholder + return []; + } catch (\Exception $e) { + return []; + } + } + + /** + * Mark messages as read. + * + * @param string $messageId WhatsApp message ID to mark as read + * + * @return array{ + * success: bool, + * }|string + */ + public function markAsRead(string $messageId): array|string + { + try { + $payload = [ + 'messaging_product' => 'whatsapp', + 'status' => 'read', + 'message_id' => $messageId, + ]; + + $response = $this->httpClient->request('POST', "https://graph.facebook.com/v18.0/{$this->phoneNumberId}/messages", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error marking as read: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'success' => true, + ]; + } catch (\Exception $e) { + return 'Error marking as read: '.$e->getMessage(); + } + } + + /** + * Send interactive message with buttons. + * + * @param string $to Recipient phone number + * @param string $header Header text + * @param string $body Body text + * @param string $footer Footer text + * @param array $buttons Array of buttons + * + * @return array{ + * messaging_product: string, + * contacts: array, + * messages: array, + * }|string + */ + public function sendInteractiveMessage( + string $to, + string $header, + string $body, + string $footer = '', + array $buttons = [], + ): array|string { + try { + $payload = [ + 'messaging_product' => 'whatsapp', + 'to' => $to, + 'type' => 'interactive', + 'interactive' => [ + 'type' => 'button', + 'header' => [ + 'type' => 'text', + 'text' => $header, + ], + 'body' => [ + 'text' => $body, + ], + 'action' => [ + 'buttons' => \array_slice($buttons, 0, 3), // WhatsApp allows max 3 buttons + ], + ], + ]; + + if ($footer) { + $payload['interactive']['footer'] = [ + 'text' => $footer, + ]; + } + + $response = $this->httpClient->request('POST', "https://graph.facebook.com/v18.0/{$this->phoneNumberId}/messages", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error sending interactive message: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'messaging_product' => $data['messaging_product'], + 'contacts' => $data['contacts'] ?? [], + 'messages' => $data['messages'] ?? [], + ]; + } catch (\Exception $e) { + return 'Error sending interactive message: '.$e->getMessage(); + } + } + + /** + * Send list message. + * + * @param string $to Recipient phone number + * @param string $header Header text + * @param string $body Body text + * @param string $footer Footer text + * @param string $buttonText Button text + * @param array $sections Array of list sections + * + * @return array{ + * messaging_product: string, + * contacts: array, + * messages: array, + * }|string + */ + public function sendListMessage( + string $to, + string $header, + string $body, + string $footer = '', + string $buttonText = 'Choose an option', + array $sections = [], + ): array|string { + try { + $payload = [ + 'messaging_product' => 'whatsapp', + 'to' => $to, + 'type' => 'interactive', + 'interactive' => [ + 'type' => 'list', + 'header' => [ + 'type' => 'text', + 'text' => $header, + ], + 'body' => [ + 'text' => $body, + ], + 'action' => [ + 'button' => $buttonText, + 'sections' => $sections, + ], + ], + ]; + + if ($footer) { + $payload['interactive']['footer'] = [ + 'text' => $footer, + ]; + } + + $response = $this->httpClient->request('POST', "https://graph.facebook.com/v18.0/{$this->phoneNumberId}/messages", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error sending list message: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'messaging_product' => $data['messaging_product'], + 'contacts' => $data['contacts'] ?? [], + 'messages' => $data['messages'] ?? [], + ]; + } catch (\Exception $e) { + return 'Error sending list message: '.$e->getMessage(); + } + } + + /** + * Get WhatsApp Business API profile information. + * + * @return array{ + * about: string, + * address: string, + * description: string, + * email: string, + * profile_picture_url: string, + * websites: array, + * vertical: string, + * }|string + */ + public function getProfile(): array|string + { + try { + $response = $this->httpClient->request('GET', "https://graph.facebook.com/v18.0/{$this->phoneNumberId}/whatsapp_business_profile", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting profile: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'about' => $data['about'] ?? '', + 'address' => $data['address'] ?? '', + 'description' => $data['description'] ?? '', + 'email' => $data['email'] ?? '', + 'profile_picture_url' => $data['profile_picture_url'] ?? '', + 'websites' => $data['websites'] ?? [], + 'vertical' => $data['vertical'] ?? '', + ]; + } catch (\Exception $e) { + return 'Error getting profile: '.$e->getMessage(); + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Wikidata.php b/src/agent/src/Toolbox/Tool/Wikidata.php new file mode 100644 index 000000000..800772d01 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Wikidata.php @@ -0,0 +1,418 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('wikidata_search', 'Tool that searches Wikidata entities')] +#[AsTool('wikidata_get_entity', 'Tool that gets Wikidata entity details', method: 'getEntity')] +#[AsTool('wikidata_get_claims', 'Tool that gets Wikidata entity claims', method: 'getClaims')] +#[AsTool('wikidata_sparql_query', 'Tool that executes SPARQL queries on Wikidata', method: 'sparqlQuery')] +#[AsTool('wikidata_get_labels', 'Tool that gets Wikidata entity labels', method: 'getLabels')] +#[AsTool('wikidata_get_descriptions', 'Tool that gets Wikidata entity descriptions', method: 'getDescriptions')] +final readonly class Wikidata +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $baseUrl = 'https://www.wikidata.org/w/api.php', + private string $sparqlUrl = 'https://query.wikidata.org/sparql', + private array $options = [], + ) { + } + + /** + * Search Wikidata entities. + * + * @param string $search Search query + * @param string $language Language code + * @param int $limit Number of results + * @param string $continue Continue token for pagination + * + * @return array{ + * searchinfo: array{ + * search: string, + * totalhits: int, + * }, + * search: array, + * continue: array|null, + * } + */ + public function __invoke( + string $search, + string $language = 'en', + int $limit = 10, + string $continue = '', + ): array { + try { + $params = [ + 'action' => 'wbsearchentities', + 'search' => $search, + 'language' => $language, + 'limit' => min(max($limit, 1), 50), + 'format' => 'json', + ]; + + if ($continue) { + $params['continue'] = $continue; + } + + $response = $this->httpClient->request('GET', $this->baseUrl, [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + return [ + 'searchinfo' => [ + 'search' => $data['searchinfo']['search'] ?? $search, + 'totalhits' => $data['searchinfo']['totalhits'] ?? 0, + ], + 'search' => array_map(fn ($result) => [ + 'id' => $result['id'], + 'title' => $result['title'], + 'pageid' => $result['pageid'], + 'size' => $result['size'] ?? 0, + 'wordcount' => $result['wordcount'] ?? 0, + 'snippet' => $result['snippet'] ?? '', + 'timestamp' => $result['timestamp'] ?? '', + 'description' => $result['description'] ?? '', + ], $data['search'] ?? []), + 'continue' => $data['continue'] ?? null, + ]; + } catch (\Exception $e) { + return [ + 'searchinfo' => [ + 'search' => $search, + 'totalhits' => 0, + ], + 'search' => [], + 'continue' => null, + ]; + } + } + + /** + * Get Wikidata entity details. + * + * @param string $entityId Entity ID (e.g., Q42) + * @param string $language Language code + * + * @return array{ + * id: string, + * type: string, + * labels: array, + * descriptions: array, + * aliases: array>, + * claims: array, + * type: string, + * rank: string, + * qualifiers: array, + * references: array>, + * }>>, + * sitelinks: array, + * url: string, + * }>, + * }|string + */ + public function getEntity( + string $entityId, + string $language = 'en', + ): array|string { + try { + $params = [ + 'action' => 'wbgetentities', + 'ids' => $entityId, + 'languages' => $language, + 'format' => 'json', + 'props' => 'labels|descriptions|aliases|claims|sitelinks', + ]; + + $response = $this->httpClient->request('GET', $this->baseUrl, [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting entity: '.($data['error']['info'] ?? 'Unknown error'); + } + + $entity = $data['entities'][$entityId] ?? null; + if (!$entity) { + return 'Entity not found'; + } + + return [ + 'id' => $entity['id'], + 'type' => $entity['type'], + 'labels' => $entity['labels'] ?? [], + 'descriptions' => $entity['descriptions'] ?? [], + 'aliases' => $entity['aliases'] ?? [], + 'claims' => $entity['claims'] ?? [], + 'sitelinks' => array_map(fn ($sitelink) => [ + 'site' => $sitelink['site'], + 'title' => $sitelink['title'], + 'badges' => $sitelink['badges'] ?? [], + 'url' => $sitelink['url'] ?? '', + ], $entity['sitelinks'] ?? []), + ]; + } catch (\Exception $e) { + return 'Error getting entity: '.$e->getMessage(); + } + } + + /** + * Get Wikidata entity claims. + * + * @param string $entityId Entity ID + * @param string $propertyId Property ID to filter by + * @param string $language Language code + * + * @return array|null, + * datatype: string, + * }, + * type: string, + * rank: string, + * qualifiers: array, + * references: array>, + * }> + */ + public function getClaims( + string $entityId, + string $propertyId = '', + string $language = 'en', + ): array { + try { + $params = [ + 'action' => 'wbgetclaims', + 'entity' => $entityId, + 'format' => 'json', + ]; + + if ($propertyId) { + $params['property'] = $propertyId; + } + + $response = $this->httpClient->request('GET', $this->baseUrl, [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + $claims = []; + foreach ($data['claims'] ?? [] as $propClaims) { + foreach ($propClaims as $claim) { + $claims[] = [ + 'id' => $claim['id'], + 'mainsnak' => [ + 'snaktype' => $claim['mainsnak']['snaktype'], + 'property' => $claim['mainsnak']['property'], + 'datavalue' => $claim['mainsnak']['datavalue'] ?? null, + 'datatype' => $claim['mainsnak']['datatype'], + ], + 'type' => $claim['type'], + 'rank' => $claim['rank'], + 'qualifiers' => $claim['qualifiers'] ?? [], + 'references' => $claim['references'] ?? [], + ]; + } + } + + return $claims; + } catch (\Exception $e) { + return []; + } + } + + /** + * Execute SPARQL query on Wikidata. + * + * @param string $query SPARQL query + * @param string $format Output format (json, xml, csv, tsv) + * + * @return array{ + * head: array{ + * vars: array, + * }, + * results: array{ + * bindings: array>, + * }, + * }|string + */ + public function sparqlQuery( + string $query, + string $format = 'json', + ): array|string { + try { + $params = [ + 'query' => $query, + 'format' => $format, + ]; + + $response = $this->httpClient->request('GET', $this->sparqlUrl, [ + 'query' => array_merge($this->options, $params), + ]); + + if ('json' !== $format) { + return $response->getContent(); + } + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'SPARQL query error: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'head' => [ + 'vars' => $data['head']['vars'] ?? [], + ], + 'results' => [ + 'bindings' => array_map(fn ($binding) => array_map(fn ($value) => [ + 'type' => $value['type'], + 'value' => $value['value'], + 'datatype' => $value['datatype'] ?? null, + 'xml:lang' => $value['xml:lang'] ?? null, + ], $binding), + $data['results']['bindings'] ?? [] + ), + ], + ]; + } catch (\Exception $e) { + return 'SPARQL query error: '.$e->getMessage(); + } + } + + /** + * Get Wikidata entity labels. + * + * @param string $entityId Entity ID + * @param string $language Language code + * + * @return array + */ + public function getLabels( + string $entityId, + string $language = 'en', + ): array { + try { + $params = [ + 'action' => 'wbgetentities', + 'ids' => $entityId, + 'languages' => $language, + 'format' => 'json', + 'props' => 'labels', + ]; + + $response = $this->httpClient->request('GET', $this->baseUrl, [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error']) || !isset($data['entities'][$entityId])) { + return []; + } + + return $data['entities'][$entityId]['labels'] ?? []; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Wikidata entity descriptions. + * + * @param string $entityId Entity ID + * @param string $language Language code + * + * @return array + */ + public function getDescriptions( + string $entityId, + string $language = 'en', + ): array { + try { + $params = [ + 'action' => 'wbgetentities', + 'ids' => $entityId, + 'languages' => $language, + 'format' => 'json', + 'props' => 'descriptions', + ]; + + $response = $this->httpClient->request('GET', $this->baseUrl, [ + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error']) || !isset($data['entities'][$entityId])) { + return []; + } + + return $data['entities'][$entityId]['descriptions'] ?? []; + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/WooCommerce.php b/src/agent/src/Toolbox/Tool/WooCommerce.php new file mode 100644 index 000000000..057a027ae --- /dev/null +++ b/src/agent/src/Toolbox/Tool/WooCommerce.php @@ -0,0 +1,931 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('woocommerce_get_products', 'Tool that gets WooCommerce products')] +#[AsTool('woocommerce_create_product', 'Tool that creates WooCommerce products', method: 'createProduct')] +#[AsTool('woocommerce_get_orders', 'Tool that gets WooCommerce orders', method: 'getOrders')] +#[AsTool('woocommerce_create_order', 'Tool that creates WooCommerce orders', method: 'createOrder')] +#[AsTool('woocommerce_get_customers', 'Tool that gets WooCommerce customers', method: 'getCustomers')] +#[AsTool('woocommerce_create_customer', 'Tool that creates WooCommerce customers', method: 'createCustomer')] +final readonly class WooCommerce +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $consumerKey, + #[\SensitiveParameter] private string $consumerSecret, + private string $storeUrl, + private string $apiVersion = 'wc/v3', + private array $options = [], + ) { + } + + /** + * Get WooCommerce products. + * + * @param int $perPage Number of products per page + * @param int $page Page number + * @param string $search Search term + * @param string $category Category slug + * @param string $status Product status (draft, pending, private, publish) + * @param string $stockStatus Stock status (instock, outofstock, onbackorder) + * @param string $orderBy Order by field (date, id, include, title, slug, price, popularity, rating) + * @param string $order Order direction (asc, desc) + * + * @return array, + * download_limit: int, + * download_expiry: int, + * external_url: string, + * button_text: string, + * tax_status: string, + * tax_class: string, + * manage_stock: bool, + * stock_quantity: int|null, + * stock_status: string, + * backorders: string, + * backorders_allowed: bool, + * backordered: bool, + * sold_individually: bool, + * weight: string, + * dimensions: array{length: string, width: string, height: string}, + * shipping_required: bool, + * shipping_taxable: bool, + * shipping_class: string, + * shipping_class_id: int, + * reviews_allowed: bool, + * average_rating: string, + * rating_count: int, + * related_ids: array, + * upsell_ids: array, + * cross_sell_ids: array, + * parent_id: int, + * purchase_note: string, + * categories: array, + * tags: array, + * images: array, + * attributes: array}>, + * default_attributes: array, + * variations: array, + * grouped_products: array, + * menu_order: int, + * meta_data: array, + * _links: array{self: array, collection: array}, + * }> + */ + public function __invoke( + int $perPage = 10, + int $page = 1, + string $search = '', + string $category = '', + string $status = '', + string $stockStatus = '', + string $orderBy = 'date', + string $order = 'desc', + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + 'orderby' => $orderBy, + 'order' => $order, + ]; + + if ($search) { + $params['search'] = $search; + } + if ($category) { + $params['category'] = $category; + } + if ($status) { + $params['status'] = $status; + } + if ($stockStatus) { + $params['stock_status'] = $stockStatus; + } + + $response = $this->httpClient->request('GET', $this->buildUrl('products'), [ + 'auth_basic' => [$this->consumerKey, $this->consumerSecret], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['code'])) { + return []; + } + + return array_map(fn ($product) => [ + 'id' => $product['id'], + 'name' => $product['name'], + 'slug' => $product['slug'], + 'permalink' => $product['permalink'], + 'date_created' => $product['date_created'], + 'date_modified' => $product['date_modified'], + 'type' => $product['type'], + 'status' => $product['status'], + 'featured' => $product['featured'], + 'catalog_visibility' => $product['catalog_visibility'], + 'description' => $product['description'], + 'short_description' => $product['short_description'], + 'sku' => $product['sku'], + 'price' => $product['price'], + 'regular_price' => $product['regular_price'], + 'sale_price' => $product['sale_price'], + 'date_on_sale_from' => $product['date_on_sale_from'], + 'date_on_sale_to' => $product['date_on_sale_to'], + 'on_sale' => $product['on_sale'], + 'purchasable' => $product['purchasable'], + 'total_sales' => $product['total_sales'], + 'virtual' => $product['virtual'], + 'downloadable' => $product['downloadable'], + 'downloads' => $product['downloads'], + 'download_limit' => $product['download_limit'], + 'download_expiry' => $product['download_expiry'], + 'external_url' => $product['external_url'], + 'button_text' => $product['button_text'], + 'tax_status' => $product['tax_status'], + 'tax_class' => $product['tax_class'], + 'manage_stock' => $product['manage_stock'], + 'stock_quantity' => $product['stock_quantity'], + 'stock_status' => $product['stock_status'], + 'backorders' => $product['backorders'], + 'backorders_allowed' => $product['backorders_allowed'], + 'backordered' => $product['backordered'], + 'sold_individually' => $product['sold_individually'], + 'weight' => $product['weight'], + 'dimensions' => $product['dimensions'], + 'shipping_required' => $product['shipping_required'], + 'shipping_taxable' => $product['shipping_taxable'], + 'shipping_class' => $product['shipping_class'], + 'shipping_class_id' => $product['shipping_class_id'], + 'reviews_allowed' => $product['reviews_allowed'], + 'average_rating' => $product['average_rating'], + 'rating_count' => $product['rating_count'], + 'related_ids' => $product['related_ids'], + 'upsell_ids' => $product['upsell_ids'], + 'cross_sell_ids' => $product['cross_sell_ids'], + 'parent_id' => $product['parent_id'], + 'purchase_note' => $product['purchase_note'], + 'categories' => array_map(fn ($cat) => [ + 'id' => $cat['id'], + 'name' => $cat['name'], + 'slug' => $cat['slug'], + ], $product['categories']), + 'tags' => array_map(fn ($tag) => [ + 'id' => $tag['id'], + 'name' => $tag['name'], + 'slug' => $tag['slug'], + ], $product['tags']), + 'images' => array_map(fn ($img) => [ + 'id' => $img['id'], + 'src' => $img['src'], + 'name' => $img['name'], + 'alt' => $img['alt'], + ], $product['images']), + 'attributes' => array_map(fn ($attr) => [ + 'id' => $attr['id'], + 'name' => $attr['name'], + 'position' => $attr['position'], + 'visible' => $attr['visible'], + 'variation' => $attr['variation'], + 'options' => $attr['options'], + ], $product['attributes']), + 'default_attributes' => array_map(fn ($attr) => [ + 'id' => $attr['id'], + 'name' => $attr['name'], + 'option' => $attr['option'], + ], $product['default_attributes']), + 'variations' => $product['variations'], + 'grouped_products' => $product['grouped_products'], + 'menu_order' => $product['menu_order'], + 'meta_data' => array_map(fn ($meta) => [ + 'id' => $meta['id'], + 'key' => $meta['key'], + 'value' => $meta['value'], + ], $product['meta_data']), + '_links' => $product['_links'], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a WooCommerce product. + * + * @param string $name Product name + * @param string $type Product type (simple, grouped, external, variable) + * @param string $regularPrice Regular price + * @param string $description Product description + * @param string $shortDescription Short description + * @param string $sku Product SKU + * @param string $salePrice Sale price + * @param bool $manageStock Whether to manage stock + * @param int $stockQuantity Stock quantity + * @param string $stockStatus Stock status + * @param bool $virtual Whether product is virtual + * @param bool $downloadable Whether product is downloadable + * @param array $categories Product categories + * @param array $images Product images + * + * @return array{ + * id: int, + * name: string, + * slug: string, + * permalink: string, + * date_created: string, + * date_modified: string, + * type: string, + * status: string, + * featured: bool, + * catalog_visibility: string, + * description: string, + * short_description: string, + * sku: string, + * price: string, + * regular_price: string, + * sale_price: string, + * on_sale: bool, + * purchasable: bool, + * total_sales: int, + * virtual: bool, + * downloadable: bool, + * stock_quantity: int|null, + * stock_status: string, + * categories: array, + * images: array, + * }|string + */ + public function createProduct( + string $name, + string $type = 'simple', + string $regularPrice = '', + string $description = '', + string $shortDescription = '', + string $sku = '', + string $salePrice = '', + bool $manageStock = false, + int $stockQuantity = 0, + string $stockStatus = 'instock', + bool $virtual = false, + bool $downloadable = false, + array $categories = [], + array $images = [], + ): array|string { + try { + $payload = [ + 'name' => $name, + 'type' => $type, + 'status' => 'publish', + 'featured' => false, + 'catalog_visibility' => 'visible', + 'description' => $description, + 'short_description' => $shortDescription, + 'sku' => $sku, + 'regular_price' => $regularPrice, + 'sale_price' => $salePrice, + 'virtual' => $virtual, + 'downloadable' => $downloadable, + 'manage_stock' => $manageStock, + 'stock_quantity' => $stockQuantity, + 'stock_status' => $stockStatus, + 'backorders' => 'no', + 'sold_individually' => false, + 'weight' => '', + 'dimensions' => ['length' => '', 'width' => '', 'height' => ''], + 'shipping_required' => !$virtual, + 'shipping_taxable' => true, + 'reviews_allowed' => true, + 'categories' => $categories, + 'images' => $images, + 'attributes' => [], + 'default_attributes' => [], + 'variations' => [], + 'grouped_products' => [], + ]; + + $response = $this->httpClient->request('POST', $this->buildUrl('products'), [ + 'auth_basic' => [$this->consumerKey, $this->consumerSecret], + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['code'])) { + return 'Error creating product: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'name' => $data['name'], + 'slug' => $data['slug'], + 'permalink' => $data['permalink'], + 'date_created' => $data['date_created'], + 'date_modified' => $data['date_modified'], + 'type' => $data['type'], + 'status' => $data['status'], + 'featured' => $data['featured'], + 'catalog_visibility' => $data['catalog_visibility'], + 'description' => $data['description'], + 'short_description' => $data['short_description'], + 'sku' => $data['sku'], + 'price' => $data['price'], + 'regular_price' => $data['regular_price'], + 'sale_price' => $data['sale_price'], + 'on_sale' => $data['on_sale'], + 'purchasable' => $data['purchasable'], + 'total_sales' => $data['total_sales'], + 'virtual' => $data['virtual'], + 'downloadable' => $data['downloadable'], + 'stock_quantity' => $data['stock_quantity'], + 'stock_status' => $data['stock_status'], + 'categories' => array_map(fn ($cat) => [ + 'id' => $cat['id'], + 'name' => $cat['name'], + 'slug' => $cat['slug'], + ], $data['categories']), + 'images' => array_map(fn ($img) => [ + 'id' => $img['id'], + 'src' => $img['src'], + 'name' => $img['name'], + 'alt' => $img['alt'], + ], $data['images']), + ]; + } catch (\Exception $e) { + return 'Error creating product: '.$e->getMessage(); + } + } + + /** + * Get WooCommerce orders. + * + * @param int $perPage Number of orders per page + * @param int $page Page number + * @param string $status Order status + * @param string $customer Customer ID + * @param string $product Product ID + * @param string $orderBy Order by field + * @param string $order Order direction + * + * @return array, + * line_items: array, + * meta_data: array, + * sku: string, + * price: float, + * image: array{id: int, src: string, name: string, alt: string}|null, + * }>, + * tax_lines: array, + * shipping_lines: array, + * fee_lines: array, + * coupon_lines: array, + * refunds: array, + * payment_url: string, + * is_editable: bool, + * needs_payment: bool, + * needs_processing: bool, + * date_created_gmt: string, + * date_modified_gmt: string, + * date_completed_gmt: string|null, + * date_paid_gmt: string|null, + * currency_symbol: string, + * _links: array{self: array, collection: array}, + * }> + */ + public function getOrders( + int $perPage = 10, + int $page = 1, + string $status = '', + string $customer = '', + string $product = '', + string $orderBy = 'date', + string $order = 'desc', + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + 'orderby' => $orderBy, + 'order' => $order, + ]; + + if ($status) { + $params['status'] = $status; + } + if ($customer) { + $params['customer'] = $customer; + } + if ($product) { + $params['product'] = $product; + } + + $response = $this->httpClient->request('GET', $this->buildUrl('orders'), [ + 'auth_basic' => [$this->consumerKey, $this->consumerSecret], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['code'])) { + return []; + } + + return array_map(fn ($order) => [ + 'id' => $order['id'], + 'parent_id' => $order['parent_id'], + 'status' => $order['status'], + 'currency' => $order['currency'], + 'date_created' => $order['date_created'], + 'date_modified' => $order['date_modified'], + 'discount_total' => $order['discount_total'], + 'discount_tax' => $order['discount_tax'], + 'shipping_total' => $order['shipping_total'], + 'shipping_tax' => $order['shipping_tax'], + 'cart_tax' => $order['cart_tax'], + 'total' => $order['total'], + 'total_tax' => $order['total_tax'], + 'customer_id' => $order['customer_id'], + 'order_key' => $order['order_key'], + 'billing' => $order['billing'], + 'shipping' => $order['shipping'], + 'payment_method' => $order['payment_method'], + 'payment_method_title' => $order['payment_method_title'], + 'transaction_id' => $order['transaction_id'], + 'customer_ip_address' => $order['customer_ip_address'], + 'customer_user_agent' => $order['customer_user_agent'], + 'created_via' => $order['created_via'], + 'customer_note' => $order['customer_note'], + 'date_completed' => $order['date_completed'], + 'date_paid' => $order['date_paid'], + 'cart_hash' => $order['cart_hash'], + 'number' => $order['number'], + 'meta_data' => array_map(fn ($meta) => [ + 'id' => $meta['id'], + 'key' => $meta['key'], + 'value' => $meta['value'], + ], $order['meta_data']), + 'line_items' => array_map(fn ($item) => [ + 'id' => $item['id'], + 'name' => $item['name'], + 'product_id' => $item['product_id'], + 'variation_id' => $item['variation_id'], + 'quantity' => $item['quantity'], + 'tax_class' => $item['tax_class'], + 'subtotal' => $item['subtotal'], + 'subtotal_tax' => $item['subtotal_tax'], + 'total' => $item['total'], + 'total_tax' => $item['total_tax'], + 'taxes' => $item['taxes'], + 'meta_data' => $item['meta_data'], + 'sku' => $item['sku'], + 'price' => $item['price'], + 'image' => $item['image'], + ], $order['line_items']), + 'tax_lines' => $order['tax_lines'], + 'shipping_lines' => $order['shipping_lines'], + 'fee_lines' => $order['fee_lines'], + 'coupon_lines' => $order['coupon_lines'], + 'refunds' => $order['refunds'], + 'payment_url' => $order['payment_url'], + 'is_editable' => $order['is_editable'], + 'needs_payment' => $order['needs_payment'], + 'needs_processing' => $order['needs_processing'], + 'date_created_gmt' => $order['date_created_gmt'], + 'date_modified_gmt' => $order['date_modified_gmt'], + 'date_completed_gmt' => $order['date_completed_gmt'], + 'date_paid_gmt' => $order['date_paid_gmt'], + 'currency_symbol' => $order['currency_symbol'], + '_links' => $order['_links'], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a WooCommerce order. + * + * @param array{ + * first_name: string, + * last_name: string, + * email: string, + * phone: string, + * company: string, + * address_1: string, + * address_2: string, + * city: string, + * state: string, + * postcode: string, + * country: string, + * } $billing Billing address + * @param array{ + * first_name: string, + * last_name: string, + * company: string, + * address_1: string, + * address_2: string, + * city: string, + * state: string, + * postcode: string, + * country: string, + * } $shipping Shipping address + * @param array $lineItems Order line items + * @param string $paymentMethod Payment method + * @param string $status Order status + * @param string $currency Currency code + * + * @return array{ + * id: int, + * parent_id: int, + * status: string, + * currency: string, + * date_created: string, + * date_modified: string, + * total: string, + * customer_id: int, + * order_key: string, + * billing: array, + * shipping: array, + * payment_method: string, + * payment_method_title: string, + * line_items: array>, + * }|string + */ + public function createOrder( + array $billing, + array $shipping, + array $lineItems, + string $paymentMethod = 'bacs', + string $status = 'pending', + string $currency = 'USD', + ): array|string { + try { + $payload = [ + 'payment_method' => $paymentMethod, + 'payment_method_title' => ucfirst($paymentMethod), + 'set_paid' => false, + 'billing' => $billing, + 'shipping' => $shipping, + 'line_items' => $lineItems, + 'shipping_lines' => [], + 'fee_lines' => [], + 'coupon_lines' => [], + 'status' => $status, + 'currency' => $currency, + ]; + + $response = $this->httpClient->request('POST', $this->buildUrl('orders'), [ + 'auth_basic' => [$this->consumerKey, $this->consumerSecret], + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['code'])) { + return 'Error creating order: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'parent_id' => $data['parent_id'], + 'status' => $data['status'], + 'currency' => $data['currency'], + 'date_created' => $data['date_created'], + 'date_modified' => $data['date_modified'], + 'total' => $data['total'], + 'customer_id' => $data['customer_id'], + 'order_key' => $data['order_key'], + 'billing' => $data['billing'], + 'shipping' => $data['shipping'], + 'payment_method' => $data['payment_method'], + 'payment_method_title' => $data['payment_method_title'], + 'line_items' => $data['line_items'], + ]; + } catch (\Exception $e) { + return 'Error creating order: '.$e->getMessage(); + } + } + + /** + * Get WooCommerce customers. + * + * @param int $perPage Number of customers per page + * @param int $page Page number + * @param string $search Search term + * @param string $email Customer email + * @param string $role Customer role + * @param string $orderBy Order by field + * @param string $order Order direction + * + * @return array, + * shipping: array, + * is_paying_customer: bool, + * avatar_url: string, + * meta_data: array, + * _links: array{self: array, collection: array}, + * }> + */ + public function getCustomers( + int $perPage = 10, + int $page = 1, + string $search = '', + string $email = '', + string $role = '', + string $orderBy = 'registered_date', + string $order = 'desc', + ): array { + try { + $params = [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => max($page, 1), + 'orderby' => $orderBy, + 'order' => $order, + ]; + + if ($search) { + $params['search'] = $search; + } + if ($email) { + $params['email'] = $email; + } + if ($role) { + $params['role'] = $role; + } + + $response = $this->httpClient->request('GET', $this->buildUrl('customers'), [ + 'auth_basic' => [$this->consumerKey, $this->consumerSecret], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['code'])) { + return []; + } + + return array_map(fn ($customer) => [ + 'id' => $customer['id'], + 'date_created' => $customer['date_created'], + 'date_created_gmt' => $customer['date_created_gmt'], + 'date_modified' => $customer['date_modified'], + 'date_modified_gmt' => $customer['date_modified_gmt'], + 'email' => $customer['email'], + 'first_name' => $customer['first_name'], + 'last_name' => $customer['last_name'], + 'role' => $customer['role'], + 'username' => $customer['username'], + 'billing' => $customer['billing'], + 'shipping' => $customer['shipping'], + 'is_paying_customer' => $customer['is_paying_customer'], + 'avatar_url' => $customer['avatar_url'], + 'meta_data' => array_map(fn ($meta) => [ + 'id' => $meta['id'], + 'key' => $meta['key'], + 'value' => $meta['value'], + ], $customer['meta_data']), + '_links' => $customer['_links'], + ], $data); + } catch (\Exception $e) { + return []; + } + } + + /** + * Create a WooCommerce customer. + * + * @param string $email Customer email + * @param string $firstName First name + * @param string $lastName Last name + * @param string $username Username + * @param string $password Password + * @param array{ + * first_name: string, + * last_name: string, + * company: string, + * address_1: string, + * address_2: string, + * city: string, + * state: string, + * postcode: string, + * country: string, + * email: string, + * phone: string, + * } $billing Billing address + * @param array{ + * first_name: string, + * last_name: string, + * company: string, + * address_1: string, + * address_2: string, + * city: string, + * state: string, + * postcode: string, + * country: string, + * } $shipping Shipping address + * + * @return array{ + * id: int, + * date_created: string, + * date_created_gmt: string, + * date_modified: string, + * date_modified_gmt: string, + * email: string, + * first_name: string, + * last_name: string, + * role: string, + * username: string, + * billing: array, + * shipping: array, + * is_paying_customer: bool, + * avatar_url: string, + * }|string + */ + public function createCustomer( + string $email, + string $firstName, + string $lastName, + string $username, + string $password, + array $billing = [], + array $shipping = [], + ): array|string { + try { + $payload = [ + 'email' => $email, + 'first_name' => $firstName, + 'last_name' => $lastName, + 'username' => $username, + 'password' => $password, + 'billing' => $billing, + 'shipping' => $shipping, + ]; + + $response = $this->httpClient->request('POST', $this->buildUrl('customers'), [ + 'auth_basic' => [$this->consumerKey, $this->consumerSecret], + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['code'])) { + return 'Error creating customer: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'date_created' => $data['date_created'], + 'date_created_gmt' => $data['date_created_gmt'], + 'date_modified' => $data['date_modified'], + 'date_modified_gmt' => $data['date_modified_gmt'], + 'email' => $data['email'], + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'role' => $data['role'], + 'username' => $data['username'], + 'billing' => $data['billing'], + 'shipping' => $data['shipping'], + 'is_paying_customer' => $data['is_paying_customer'], + 'avatar_url' => $data['avatar_url'], + ]; + } catch (\Exception $e) { + return 'Error creating customer: '.$e->getMessage(); + } + } + + /** + * Build API URL. + */ + private function buildUrl(string $endpoint): string + { + $baseUrl = rtrim($this->storeUrl, '/'); + + return "{$baseUrl}/wp-json/wc/{$this->apiVersion}/{$endpoint}"; + } +} diff --git a/src/agent/src/Toolbox/Tool/YahooFinance.php b/src/agent/src/Toolbox/Tool/YahooFinance.php new file mode 100644 index 000000000..761f3ce94 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/YahooFinance.php @@ -0,0 +1,244 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('yahoo_finance_news', 'Tool that searches financial news on Yahoo Finance')] +#[AsTool('yahoo_finance_quote', 'Tool that gets stock quote information from Yahoo Finance', method: 'getQuote')] +final readonly class YahooFinance +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private array $options = [], + ) { + } + + /** + * @param string $query company ticker query to look up + * @param int $topK The number of results to return + * + * @return array + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + int $topK = 10, + ): array { + try { + $ticker = strtoupper(trim($query)); + + // Get company information first + $companyInfo = $this->getCompanyInfo($ticker); + if (empty($companyInfo)) { + return [ + [ + 'title' => 'Company Not Found', + 'summary' => "Company ticker {$ticker} not found.", + 'link' => '', + 'published' => '', + 'source' => 'Error', + ], + ]; + } + + // Get news for the ticker + $newsData = $this->fetchYahooFinanceNews($ticker, $topK); + + if (empty($newsData)) { + return [ + [ + 'title' => 'No News Found', + 'summary' => "No news found for company ticker {$ticker}.", + 'link' => '', + 'published' => '', + 'source' => 'Yahoo Finance', + ], + ]; + } + + return $newsData; + } catch (\Exception $e) { + return [ + [ + 'title' => 'Error', + 'summary' => 'Unable to fetch financial news: '.$e->getMessage(), + 'link' => '', + 'published' => '', + 'source' => 'Error', + ], + ]; + } + } + + /** + * Get stock quote information. + * + * @param string $ticker The stock ticker symbol + * + * @return array{ + * symbol: string, + * name: string, + * price: float, + * change: float, + * changePercent: float, + * volume: int, + * marketCap: string, + * currency: string, + * }|null + */ + public function getQuote(string $ticker): ?array + { + try { + $ticker = strtoupper(trim($ticker)); + + // Use Yahoo Finance API (unofficial) + $response = $this->httpClient->request('GET', "https://query1.finance.yahoo.com/v8/finance/chart/{$ticker}", [ + 'query' => [ + 'interval' => '1d', + 'range' => '1d', + ], + ]); + + $data = $response->toArray(); + + if (empty($data['chart']['result'])) { + return null; + } + + $result = $data['chart']['result'][0]; + $meta = $result['meta']; + $quote = $result['indicators']['quote'][0]; + + return [ + 'symbol' => $meta['symbol'], + 'name' => $meta['longName'] ?? $meta['shortName'] ?? $ticker, + 'price' => $meta['regularMarketPrice'] ?? 0.0, + 'change' => $meta['regularMarketPrice'] - ($meta['previousClose'] ?? 0), + 'changePercent' => (($meta['regularMarketPrice'] ?? 0) - ($meta['previousClose'] ?? 0)) / ($meta['previousClose'] ?? 1) * 100, + 'volume' => $meta['regularMarketVolume'] ?? 0, + 'marketCap' => $this->formatNumber($meta['marketCap'] ?? 0), + 'currency' => $meta['currency'] ?? 'USD', + ]; + } catch (\Exception $e) { + return null; + } + } + + /** + * Get company information. + * + * @return array|null + */ + private function getCompanyInfo(string $ticker): ?array + { + try { + $response = $this->httpClient->request('GET', 'https://query2.finance.yahoo.com/v1/finance/search', [ + 'query' => [ + 'q' => $ticker, + 'quotesCount' => 1, + 'newsCount' => 0, + ], + ]); + + $data = $response->toArray(); + + if (empty($data['quotes'])) { + return null; + } + + return $data['quotes'][0]; + } catch (\Exception $e) { + return null; + } + } + + /** + * Fetch Yahoo Finance news. + * + * @return array + */ + private function fetchYahooFinanceNews(string $ticker, int $topK): array + { + try { + $response = $this->httpClient->request('GET', 'https://query2.finance.yahoo.com/v1/finance/search', [ + 'query' => [ + 'q' => $ticker, + 'quotesCount' => 0, + 'newsCount' => $topK, + ], + ]); + + $data = $response->toArray(); + $news = $data['news'] ?? []; + $results = []; + + foreach ($news as $article) { + $results[] = [ + 'title' => $article['title'] ?? '', + 'summary' => $article['summary'] ?? '', + 'link' => $article['link'] ?? '', + 'published' => $article['providerPublishTime'] ? date('Y-m-d H:i:s', $article['providerPublishTime']) : '', + 'source' => $article['publisher'] ?? 'Yahoo Finance', + ]; + } + + return $results; + } catch (\Exception $e) { + return []; + } + } + + /** + * Format large numbers with appropriate suffixes. + */ + private function formatNumber(int $number): string + { + if ($number >= 1000000000000) { + return round($number / 1000000000000, 2).'T'; + } + + if ($number >= 1000000000) { + return round($number / 1000000000, 2).'B'; + } + + if ($number >= 1000000) { + return round($number / 1000000, 2).'M'; + } + + if ($number >= 1000) { + return round($number / 1000, 2).'K'; + } + + return (string) $number; + } +} diff --git a/src/agent/src/Toolbox/Tool/You.php b/src/agent/src/Toolbox/Tool/You.php new file mode 100644 index 000000000..c9ba23a74 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/You.php @@ -0,0 +1,806 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('you_search', 'Tool that performs AI-powered search using You.com')] +#[AsTool('you_chat', 'Tool that performs AI chat using You.com', method: 'chat')] +#[AsTool('you_summarize', 'Tool that summarizes content using You.com', method: 'summarize')] +#[AsTool('you_analyze', 'Tool that analyzes content using You.com', method: 'analyze')] +#[AsTool('you_translate', 'Tool that translates content using You.com', method: 'translate')] +#[AsTool('you_code', 'Tool that generates code using You.com', method: 'code')] +#[AsTool('you_writing', 'Tool that helps with writing using You.com', method: 'writing')] +#[AsTool('you_math', 'Tool that solves math problems using You.com', method: 'math')] +final readonly class You +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.you.com/v1', + private array $options = [], + ) { + } + + /** + * Perform AI-powered search using You.com. + * + * @param string $query Search query + * @param array $searchTypes Types of search (web, images, videos, news) + * @param string $language Language for results + * @param int $count Number of results to return + * @param string $region Region for search + * + * @return array{ + * success: bool, + * search: array{ + * query: string, + * results: array, + * total_results: int, + * search_time: float, + * ai_insights: array{ + * summary: string, + * key_points: array, + * related_topics: array, + * }, + * }, + * language: string, + * region: string, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $query, + array $searchTypes = ['web'], + string $language = 'en', + int $count = 10, + string $region = 'us', + ): array { + try { + $requestData = [ + 'query' => $query, + 'search_types' => $searchTypes, + 'language' => $language, + 'count' => max(1, min($count, 50)), + 'region' => $region, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/search", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $search = $data['search'] ?? []; + + return [ + 'success' => true, + 'search' => [ + 'query' => $query, + 'results' => array_map(fn ($result) => [ + 'title' => $result['title'] ?? '', + 'url' => $result['url'] ?? '', + 'snippet' => $result['snippet'] ?? '', + 'source' => $result['source'] ?? '', + 'published_date' => $result['published_date'] ?? null, + 'relevance_score' => $result['relevance_score'] ?? 0.0, + ], $search['results'] ?? []), + 'total_results' => $search['total_results'] ?? 0, + 'search_time' => $search['search_time'] ?? 0.0, + 'ai_insights' => [ + 'summary' => $search['ai_insights']['summary'] ?? '', + 'key_points' => $search['ai_insights']['key_points'] ?? [], + 'related_topics' => $search['ai_insights']['related_topics'] ?? [], + ], + ], + 'language' => $language, + 'region' => $region, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'search' => [ + 'query' => $query, + 'results' => [], + 'total_results' => 0, + 'search_time' => 0.0, + 'ai_insights' => [ + 'summary' => '', + 'key_points' => [], + 'related_topics' => [], + ], + ], + 'language' => $language, + 'region' => $region, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Perform AI chat using You.com. + * + * @param string $message Chat message + * @param array $conversationHistory Previous conversation history + * @param string $model AI model to use + * @param array $parameters Additional parameters + * + * @return array{ + * success: bool, + * chat: array{ + * message: string, + * response: string, + * model: string, + * conversation_id: string, + * response_time: float, + * suggestions: array, + * sources: array, + * }, + * processingTime: float, + * error: string, + * } + */ + public function chat( + string $message, + array $conversationHistory = [], + string $model = 'you-chat', + array $parameters = [], + ): array { + try { + $requestData = [ + 'message' => $message, + 'conversation_history' => $conversationHistory, + 'model' => $model, + 'parameters' => $parameters, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/chat", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $chat = $data['chat'] ?? []; + + return [ + 'success' => true, + 'chat' => [ + 'message' => $message, + 'response' => $chat['response'] ?? '', + 'model' => $model, + 'conversation_id' => $chat['conversation_id'] ?? '', + 'response_time' => $chat['response_time'] ?? 0.0, + 'suggestions' => $chat['suggestions'] ?? [], + 'sources' => array_map(fn ($source) => [ + 'title' => $source['title'] ?? '', + 'url' => $source['url'] ?? '', + 'snippet' => $source['snippet'] ?? '', + ], $chat['sources'] ?? []), + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'chat' => [ + 'message' => $message, + 'response' => '', + 'model' => $model, + 'conversation_id' => '', + 'response_time' => 0.0, + 'suggestions' => [], + 'sources' => [], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Summarize content using You.com. + * + * @param string $content Content to summarize + * @param string $contentType Type of content (text, url, document) + * @param int $maxLength Maximum summary length + * @param string $style Summary style (brief, detailed, bulleted) + * @param string $language Language for summary + * + * @return array{ + * success: bool, + * summary: array{ + * original_content: string, + * summary: string, + * length: int, + * style: string, + * key_points: array, + * keywords: array, + * confidence: float, + * compression_ratio: float, + * }, + * contentType: string, + * processingTime: float, + * error: string, + * } + */ + public function summarize( + string $content, + string $contentType = 'text', + int $maxLength = 200, + string $style = 'brief', + string $language = 'en', + ): array { + try { + $requestData = [ + 'content' => $content, + 'content_type' => $contentType, + 'max_length' => max(50, min($maxLength, 1000)), + 'style' => $style, + 'language' => $language, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/summarize", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $summary = $data['summary'] ?? []; + $originalLength = \strlen($content); + + return [ + 'success' => true, + 'summary' => [ + 'original_content' => $content, + 'summary' => $summary['summary'] ?? '', + 'length' => $summary['length'] ?? 0, + 'style' => $summary['style'] ?? $style, + 'key_points' => $summary['key_points'] ?? [], + 'keywords' => $summary['keywords'] ?? [], + 'confidence' => $summary['confidence'] ?? 0.0, + 'compression_ratio' => $originalLength > 0 ? ($summary['length'] ?? 0) / $originalLength : 0.0, + ], + 'contentType' => $contentType, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'summary' => [ + 'original_content' => $content, + 'summary' => '', + 'length' => 0, + 'style' => $style, + 'key_points' => [], + 'keywords' => [], + 'confidence' => 0.0, + 'compression_ratio' => 0.0, + ], + 'contentType' => $contentType, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze content using You.com. + * + * @param string $content Content to analyze + * @param string $contentType Type of content (text, url, document) + * @param array $analysisTypes Types of analysis to perform + * @param string $language Language for analysis + * + * @return array{ + * success: bool, + * analysis: array{ + * content: string, + * sentiment: array{ + * score: float, + * label: string, + * confidence: float, + * }, + * topics: array, + * keywords: array, + * entities: array, + * readability: array{ + * score: float, + * level: string, + * grade_level: int, + * }, + * tone: array{ + * score: float, + * label: string, + * confidence: float, + * }, + * insights: array, + * }, + * contentType: string, + * processingTime: float, + * error: string, + * } + */ + public function analyze( + string $content, + string $contentType = 'text', + array $analysisTypes = ['sentiment', 'topics', 'keywords', 'entities', 'readability', 'tone'], + string $language = 'en', + ): array { + try { + $requestData = [ + 'content' => $content, + 'content_type' => $contentType, + 'analysis_types' => $analysisTypes, + 'language' => $language, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/analyze", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $analysis = $data['analysis'] ?? []; + + return [ + 'success' => true, + 'analysis' => [ + 'content' => $content, + 'sentiment' => [ + 'score' => $analysis['sentiment']['score'] ?? 0.0, + 'label' => $analysis['sentiment']['label'] ?? 'neutral', + 'confidence' => $analysis['sentiment']['confidence'] ?? 0.0, + ], + 'topics' => $analysis['topics'] ?? [], + 'keywords' => $analysis['keywords'] ?? [], + 'entities' => array_map(fn ($entity) => [ + 'text' => $entity['text'] ?? '', + 'type' => $entity['type'] ?? '', + 'confidence' => $entity['confidence'] ?? 0.0, + ], $analysis['entities'] ?? []), + 'readability' => [ + 'score' => $analysis['readability']['score'] ?? 0.0, + 'level' => $analysis['readability']['level'] ?? 'intermediate', + 'grade_level' => $analysis['readability']['grade_level'] ?? 8, + ], + 'tone' => [ + 'score' => $analysis['tone']['score'] ?? 0.0, + 'label' => $analysis['tone']['label'] ?? 'neutral', + 'confidence' => $analysis['tone']['confidence'] ?? 0.0, + ], + 'insights' => $analysis['insights'] ?? [], + ], + 'contentType' => $contentType, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'analysis' => [ + 'content' => $content, + 'sentiment' => ['score' => 0.0, 'label' => 'neutral', 'confidence' => 0.0], + 'topics' => [], + 'keywords' => [], + 'entities' => [], + 'readability' => ['score' => 0.0, 'level' => 'intermediate', 'grade_level' => 8], + 'tone' => ['score' => 0.0, 'label' => 'neutral', 'confidence' => 0.0], + 'insights' => [], + ], + 'contentType' => $contentType, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Translate content using You.com. + * + * @param string $content Content to translate + * @param string $targetLanguage Target language code + * @param string $sourceLanguage Source language code (auto-detect if empty) + * @param string $contentType Type of content (text, url, document) + * + * @return array{ + * success: bool, + * translation: array{ + * original_text: string, + * translated_text: string, + * source_language: string, + * target_language: string, + * confidence: float, + * word_count: int, + * character_count: int, + * }, + * contentType: string, + * processingTime: float, + * error: string, + * } + */ + public function translate( + string $content, + string $targetLanguage, + string $sourceLanguage = '', + string $contentType = 'text', + ): array { + try { + $requestData = [ + 'content' => $content, + 'target_language' => $targetLanguage, + 'content_type' => $contentType, + ]; + + if ($sourceLanguage) { + $requestData['source_language'] = $sourceLanguage; + } + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/translate", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $translation = $data['translation'] ?? []; + + return [ + 'success' => true, + 'translation' => [ + 'original_text' => $content, + 'translated_text' => $translation['translated_text'] ?? '', + 'source_language' => $translation['source_language'] ?? $sourceLanguage, + 'target_language' => $targetLanguage, + 'confidence' => $translation['confidence'] ?? 0.0, + 'word_count' => $translation['word_count'] ?? 0, + 'character_count' => $translation['character_count'] ?? 0, + ], + 'contentType' => $contentType, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'translation' => [ + 'original_text' => $content, + 'translated_text' => '', + 'source_language' => $sourceLanguage, + 'target_language' => $targetLanguage, + 'confidence' => 0.0, + 'word_count' => 0, + 'character_count' => 0, + ], + 'contentType' => $contentType, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Generate code using You.com. + * + * @param string $prompt Code generation prompt + * @param string $language Programming language + * @param string $style Code style (clean, documented, optimized) + * @param array $examples Code examples for context + * + * @return array{ + * success: bool, + * code: array{ + * prompt: string, + * generated_code: string, + * language: string, + * style: string, + * explanation: string, + * complexity: string, + * best_practices: array, + * examples_used: int, + * }, + * processingTime: float, + * error: string, + * } + */ + public function code( + string $prompt, + string $language, + string $style = 'clean', + array $examples = [], + ): array { + try { + $requestData = [ + 'prompt' => $prompt, + 'language' => $language, + 'style' => $style, + 'examples' => $examples, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/code", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $code = $data['code'] ?? []; + + return [ + 'success' => true, + 'code' => [ + 'prompt' => $prompt, + 'generated_code' => $code['generated_code'] ?? '', + 'language' => $language, + 'style' => $code['style'] ?? $style, + 'explanation' => $code['explanation'] ?? '', + 'complexity' => $code['complexity'] ?? 'medium', + 'best_practices' => $code['best_practices'] ?? [], + 'examples_used' => \count($examples), + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'code' => [ + 'prompt' => $prompt, + 'generated_code' => '', + 'language' => $language, + 'style' => $style, + 'explanation' => '', + 'complexity' => 'medium', + 'best_practices' => [], + 'examples_used' => \count($examples), + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Help with writing using You.com. + * + * @param string $content Content to improve + * @param string $writingType Type of writing (essay, email, report, creative) + * @param string $tone Writing tone (formal, casual, persuasive, informative) + * @param string $purpose Writing purpose + * @param string $audience Target audience + * + * @return array{ + * success: bool, + * writing: array{ + * original_content: string, + * improved_content: string, + * writing_type: string, + * tone: string, + * purpose: string, + * audience: string, + * improvements: array, + * suggestions: array, + * word_count: int, + * readability_score: float, + * }, + * processingTime: float, + * error: string, + * } + */ + public function writing( + string $content, + string $writingType = 'general', + string $tone = 'neutral', + string $purpose = 'inform', + string $audience = 'general', + ): array { + try { + $requestData = [ + 'content' => $content, + 'writing_type' => $writingType, + 'tone' => $tone, + 'purpose' => $purpose, + 'audience' => $audience, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/writing", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $writing = $data['writing'] ?? []; + + return [ + 'success' => true, + 'writing' => [ + 'original_content' => $content, + 'improved_content' => $writing['improved_content'] ?? '', + 'writing_type' => $writingType, + 'tone' => $tone, + 'purpose' => $purpose, + 'audience' => $audience, + 'improvements' => $writing['improvements'] ?? [], + 'suggestions' => $writing['suggestions'] ?? [], + 'word_count' => $writing['word_count'] ?? 0, + 'readability_score' => $writing['readability_score'] ?? 0.0, + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'writing' => [ + 'original_content' => $content, + 'improved_content' => '', + 'writing_type' => $writingType, + 'tone' => $tone, + 'purpose' => $purpose, + 'audience' => $audience, + 'improvements' => [], + 'suggestions' => [], + 'word_count' => 0, + 'readability_score' => 0.0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Solve math problems using You.com. + * + * @param string $problem Math problem to solve + * @param string $subject Math subject (algebra, calculus, geometry, statistics) + * @param string $difficulty Problem difficulty (easy, medium, hard) + * @param bool $showSteps Whether to show solution steps + * + * @return array{ + * success: bool, + * math: array{ + * problem: string, + * solution: string, + * answer: string, + * subject: string, + * difficulty: string, + * steps: array, + * explanation: string, + * related_concepts: array, + * confidence: float, + * }, + * processingTime: float, + * error: string, + * } + */ + public function math( + string $problem, + string $subject = 'general', + string $difficulty = 'medium', + bool $showSteps = true, + ): array { + try { + $requestData = [ + 'problem' => $problem, + 'subject' => $subject, + 'difficulty' => $difficulty, + 'show_steps' => $showSteps, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/math", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $math = $data['math'] ?? []; + + return [ + 'success' => true, + 'math' => [ + 'problem' => $problem, + 'solution' => $math['solution'] ?? '', + 'answer' => $math['answer'] ?? '', + 'subject' => $subject, + 'difficulty' => $difficulty, + 'steps' => array_map(fn ($step) => [ + 'step' => $step['step'] ?? 0, + 'description' => $step['description'] ?? '', + 'calculation' => $step['calculation'] ?? '', + 'result' => $step['result'] ?? '', + ], $math['steps'] ?? []), + 'explanation' => $math['explanation'] ?? '', + 'related_concepts' => $math['related_concepts'] ?? [], + 'confidence' => $math['confidence'] ?? 0.0, + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'math' => [ + 'problem' => $problem, + 'solution' => '', + 'answer' => '', + 'subject' => $subject, + 'difficulty' => $difficulty, + 'steps' => [], + 'explanation' => '', + 'related_concepts' => [], + 'confidence' => 0.0, + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/YouTubeSearch.php b/src/agent/src/Toolbox/Tool/YouTubeSearch.php new file mode 100644 index 000000000..ee7408eb5 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/YouTubeSearch.php @@ -0,0 +1,316 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('youtube_search', 'Tool that searches YouTube for videos')] +#[AsTool('youtube_search_detailed', 'Tool that searches YouTube with detailed information', method: 'searchDetailed')] +final readonly class YouTubeSearch +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private array $options = [], + ) { + } + + /** + * Search YouTube for videos. + * + * @param string $query Search query or person name + * @param int $maxResults Maximum number of results to return + * @param string $order Sort order: relevance, date, rating, title, videoCount, viewCount + * + * @return array + */ + public function __invoke( + #[With(maximum: 500)] + string $query, + int $maxResults = 10, + string $order = 'relevance', + ): array { + try { + $response = $this->httpClient->request('GET', 'https://www.googleapis.com/youtube/v3/search', [ + 'query' => array_merge($this->options, [ + 'part' => 'snippet', + 'q' => $query, + 'type' => 'video', + 'maxResults' => $maxResults, + 'order' => $order, + 'key' => $this->apiKey, + ]), + ]); + + $data = $response->toArray(); + + if (!isset($data['items'])) { + return []; + } + + $videoIds = array_map(fn ($item) => $item['id']['videoId'], $data['items']); + + // Get detailed information for each video + return $this->getVideoDetails($videoIds, $data['items']); + } catch (\Exception $e) { + return [ + [ + 'video_id' => 'error', + 'title' => 'Search Error', + 'description' => 'Unable to search YouTube: '.$e->getMessage(), + 'channel_title' => '', + 'published_at' => '', + 'duration' => '', + 'view_count' => 0, + 'like_count' => 0, + 'url' => '', + 'thumbnail_url' => '', + ], + ]; + } + } + + /** + * Search YouTube with detailed information. + * + * @param string $query Search query or person name + * @param int $maxResults Maximum number of results to return + * @param string $order Sort order: relevance, date, rating, title, videoCount, viewCount + * + * @return array, + * category_id: string, + * }> + */ + public function searchDetailed( + #[With(maximum: 500)] + string $query, + int $maxResults = 10, + string $order = 'relevance', + ): array { + try { + $response = $this->httpClient->request('GET', 'https://www.googleapis.com/youtube/v3/search', [ + 'query' => array_merge($this->options, [ + 'part' => 'snippet', + 'q' => $query, + 'type' => 'video', + 'maxResults' => $maxResults, + 'order' => $order, + 'key' => $this->apiKey, + ]), + ]); + + $data = $response->toArray(); + + if (!isset($data['items'])) { + return []; + } + + $videoIds = array_map(fn ($item) => $item['id']['videoId'], $data['items']); + + // Get detailed information including statistics + return $this->getDetailedVideoInfo($videoIds, $data['items']); + } catch (\Exception $e) { + return [ + [ + 'video_id' => 'error', + 'title' => 'Search Error', + 'description' => 'Unable to search YouTube: '.$e->getMessage(), + 'channel_title' => '', + 'published_at' => '', + 'duration' => '', + 'view_count' => 0, + 'like_count' => 0, + 'comment_count' => 0, + 'url' => '', + 'thumbnail_url' => '', + 'tags' => [], + 'category_id' => '', + ], + ]; + } + } + + /** + * Get video details for a list of video IDs. + * + * @param array $videoIds + * @param array> $searchItems + * + * @return array> + */ + private function getVideoDetails(array $videoIds, array $searchItems): array + { + try { + $response = $this->httpClient->request('GET', 'https://www.googleapis.com/youtube/v3/videos', [ + 'query' => [ + 'part' => 'contentDetails,statistics', + 'id' => implode(',', $videoIds), + 'key' => $this->apiKey, + ], + ]); + + $data = $response->toArray(); + $videoDetails = $data['items'] ?? []; + + $results = []; + foreach ($searchItems as $index => $item) { + $videoId = $item['id']['videoId']; + $details = $this->findVideoDetails($videoDetails, $videoId); + + $results[] = [ + 'video_id' => $videoId, + 'title' => $item['snippet']['title'], + 'description' => $item['snippet']['description'], + 'channel_title' => $item['snippet']['channelTitle'], + 'published_at' => $item['snippet']['publishedAt'], + 'duration' => $details['duration'] ?? '', + 'view_count' => (int) ($details['viewCount'] ?? 0), + 'like_count' => (int) ($details['likeCount'] ?? 0), + 'url' => "https://www.youtube.com/watch?v={$videoId}", + 'thumbnail_url' => $item['snippet']['thumbnails']['high']['url'] ?? '', + ]; + } + + return $results; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get detailed video information including statistics. + * + * @param array $videoIds + * @param array> $searchItems + * + * @return array> + */ + private function getDetailedVideoInfo(array $videoIds, array $searchItems): array + { + try { + $response = $this->httpClient->request('GET', 'https://www.googleapis.com/youtube/v3/videos', [ + 'query' => [ + 'part' => 'contentDetails,statistics,snippet', + 'id' => implode(',', $videoIds), + 'key' => $this->apiKey, + ], + ]); + + $data = $response->toArray(); + $videoDetails = $data['items'] ?? []; + + $results = []; + foreach ($searchItems as $index => $item) { + $videoId = $item['id']['videoId']; + $details = $this->findVideoDetails($videoDetails, $videoId); + + $results[] = [ + 'video_id' => $videoId, + 'title' => $item['snippet']['title'], + 'description' => $item['snippet']['description'], + 'channel_title' => $item['snippet']['channelTitle'], + 'published_at' => $item['snippet']['publishedAt'], + 'duration' => $details['duration'] ?? '', + 'view_count' => (int) ($details['viewCount'] ?? 0), + 'like_count' => (int) ($details['likeCount'] ?? 0), + 'comment_count' => (int) ($details['commentCount'] ?? 0), + 'url' => "https://www.youtube.com/watch?v={$videoId}", + 'thumbnail_url' => $item['snippet']['thumbnails']['high']['url'] ?? '', + 'tags' => $details['tags'] ?? [], + 'category_id' => $details['categoryId'] ?? '', + ]; + } + + return $results; + } catch (\Exception $e) { + return []; + } + } + + /** + * Find video details by video ID. + * + * @param array> $videoDetails + * + * @return array + */ + private function findVideoDetails(array $videoDetails, string $videoId): array + { + foreach ($videoDetails as $video) { + if ($video['id'] === $videoId) { + return [ + 'duration' => $this->parseDuration($video['contentDetails']['duration'] ?? ''), + 'viewCount' => $video['statistics']['viewCount'] ?? '0', + 'likeCount' => $video['statistics']['likeCount'] ?? '0', + 'commentCount' => $video['statistics']['commentCount'] ?? '0', + 'tags' => $video['snippet']['tags'] ?? [], + 'categoryId' => $video['snippet']['categoryId'] ?? '', + ]; + } + } + + return []; + } + + /** + * Parse ISO 8601 duration to readable format. + */ + private function parseDuration(string $duration): string + { + $duration = preg_replace('/[^0-9HMS]/', '', $duration); + + if (preg_match('/PT(\d+H)?(\d+M)?(\d+S)?/', $duration, $matches)) { + $hours = isset($matches[1]) ? (int) $matches[1] : 0; + $minutes = isset($matches[2]) ? (int) $matches[2] : 0; + $seconds = isset($matches[3]) ? (int) $matches[3] : 0; + + if ($hours > 0) { + return \sprintf('%d:%02d:%02d', $hours, $minutes, $seconds); + } else { + return \sprintf('%d:%02d', $minutes, $seconds); + } + } + + return $duration; + } +} diff --git a/src/agent/src/Toolbox/Tool/Zapier.php b/src/agent/src/Toolbox/Tool/Zapier.php new file mode 100644 index 000000000..200bb6e01 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Zapier.php @@ -0,0 +1,411 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('zapier_trigger_zap', 'Tool that triggers Zapier zaps')] +#[AsTool('zapier_list_zaps', 'Tool that lists Zapier zaps', method: 'listZaps')] +#[AsTool('zapier_get_zap', 'Tool that gets Zapier zap details', method: 'getZap')] +#[AsTool('zapier_create_zap', 'Tool that creates Zapier zaps', method: 'createZap')] +#[AsTool('zapier_update_zap', 'Tool that updates Zapier zaps', method: 'updateZap')] +#[AsTool('zapier_delete_zap', 'Tool that deletes Zapier zaps', method: 'deleteZap')] +final readonly class Zapier +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + private string $baseUrl = 'https://hooks.zapier.com/hooks/catch', + private array $options = [], + ) { + } + + /** + * Trigger Zapier zap. + * + * @param string $webhookUrl Zapier webhook URL + * @param array $data Data to send to zap + * + * @return array{ + * success: bool, + * status: int, + * response: array, + * error: string, + * } + */ + public function __invoke( + string $webhookUrl, + array $data = [], + ): array { + try { + $response = $this->httpClient->request('POST', $webhookUrl, [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'json' => $data, + ]); + + $statusCode = $response->getStatusCode(); + $responseData = $response->toArray(); + + return [ + 'success' => $statusCode >= 200 && $statusCode < 300, + 'status' => $statusCode, + 'response' => $responseData, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'status' => 0, + 'response' => [], + 'error' => $e->getMessage(), + ]; + } + } + + /** + * List Zapier zaps. + * + * @param string $status Zap status filter (active, paused, draft) + * @param int $limit Number of zaps to return + * @param int $offset Offset for pagination + * + * @return array, + * }> + */ + public function listZaps( + string $status = '', + int $limit = 50, + int $offset = 0, + ): array { + try { + $params = [ + 'limit' => min(max($limit, 1), 100), + 'offset' => max($offset, 0), + ]; + + if ($status) { + $params['status'] = $status; + } + + $response = $this->httpClient->request('GET', 'https://api.zapier.com/v2/zaps', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'query' => array_merge($this->options, $params), + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return []; + } + + return array_map(fn ($zap) => [ + 'id' => $zap['id'], + 'title' => $zap['title'], + 'description' => $zap['description'] ?? '', + 'status' => $zap['status'], + 'created' => $zap['created'], + 'modified' => $zap['modified'], + 'url' => $zap['url'], + 'trigger' => [ + 'type' => $zap['trigger']['type'], + 'app' => $zap['trigger']['app'], + 'title' => $zap['trigger']['title'], + ], + 'actions' => array_map(fn ($action) => [ + 'type' => $action['type'], + 'app' => $action['app'], + 'title' => $action['title'], + ], $zap['actions'] ?? []), + ], $data['results'] ?? []); + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Zapier zap details. + * + * @param string $zapId Zap ID + * + * @return array{ + * id: string, + * title: string, + * description: string, + * status: string, + * created: string, + * modified: string, + * url: string, + * trigger: array{ + * type: string, + * app: string, + * title: string, + * fields: array, + * }, + * actions: array, + * }>, + * lastRun: string, + * runCount: int, + * }|string + */ + public function getZap(string $zapId): array|string + { + try { + $response = $this->httpClient->request('GET', "https://api.zapier.com/v2/zaps/{$zapId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return 'Error getting zap: '.($data['error']['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'title' => $data['title'], + 'description' => $data['description'] ?? '', + 'status' => $data['status'], + 'created' => $data['created'], + 'modified' => $data['modified'], + 'url' => $data['url'], + 'trigger' => [ + 'type' => $data['trigger']['type'], + 'app' => $data['trigger']['app'], + 'title' => $data['trigger']['title'], + 'fields' => $data['trigger']['fields'] ?? [], + ], + 'actions' => array_map(fn ($action) => [ + 'type' => $action['type'], + 'app' => $action['app'], + 'title' => $action['title'], + 'fields' => $action['fields'] ?? [], + ], $data['actions'] ?? []), + 'lastRun' => $data['lastRun'] ?? '', + 'runCount' => $data['runCount'] ?? 0, + ]; + } catch (\Exception $e) { + return 'Error getting zap: '.$e->getMessage(); + } + } + + /** + * Create Zapier zap. + * + * @param string $title Zap title + * @param string $description Zap description + * @param array $trigger Trigger configuration + * @param array> $actions Actions configuration + * + * @return array{ + * success: bool, + * zapId: string, + * url: string, + * error: string, + * } + */ + public function createZap( + string $title, + string $description = '', + array $trigger = [], + array $actions = [], + ): array { + try { + $body = [ + 'title' => $title, + 'description' => $description, + 'trigger' => $trigger, + 'actions' => $actions, + ]; + + $response = $this->httpClient->request('POST', 'https://api.zapier.com/v2/zaps', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $body, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return [ + 'success' => false, + 'zapId' => '', + 'url' => '', + 'error' => $data['error']['message'] ?? 'Unknown error', + ]; + } + + return [ + 'success' => true, + 'zapId' => $data['id'] ?? '', + 'url' => $data['url'] ?? '', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'zapId' => '', + 'url' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Update Zapier zap. + * + * @param string $zapId Zap ID + * @param string $title New zap title + * @param string $description New zap description + * @param string $status New zap status + * + * @return array{ + * success: bool, + * zapId: string, + * url: string, + * error: string, + * } + */ + public function updateZap( + string $zapId, + string $title = '', + string $description = '', + string $status = '', + ): array { + try { + $body = []; + + if ($title) { + $body['title'] = $title; + } + if ($description) { + $body['description'] = $description; + } + if ($status) { + $body['status'] = $status; + } + + $response = $this->httpClient->request('PATCH', "https://api.zapier.com/v2/zaps/{$zapId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $body, + ]); + + $data = $response->toArray(); + + if (isset($data['error'])) { + return [ + 'success' => false, + 'zapId' => '', + 'url' => '', + 'error' => $data['error']['message'] ?? 'Unknown error', + ]; + } + + return [ + 'success' => true, + 'zapId' => $data['id'] ?? $zapId, + 'url' => $data['url'] ?? '', + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'zapId' => '', + 'url' => '', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Delete Zapier zap. + * + * @param string $zapId Zap ID + * + * @return array{ + * success: bool, + * error: string, + * } + */ + public function deleteZap(string $zapId): array + { + try { + $response = $this->httpClient->request('DELETE', "https://api.zapier.com/v2/zaps/{$zapId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Content-Type' => 'application/json', + ], + ]); + + $statusCode = $response->getStatusCode(); + + if ($statusCode >= 400) { + $data = $response->toArray(); + + return [ + 'success' => false, + 'error' => $data['error']['message'] ?? 'Failed to delete zap', + ]; + } + + return [ + 'success' => true, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/ZenGuard.php b/src/agent/src/Toolbox/Tool/ZenGuard.php new file mode 100644 index 000000000..b02577f97 --- /dev/null +++ b/src/agent/src/Toolbox/Tool/ZenGuard.php @@ -0,0 +1,924 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('zenguard_scan', 'Tool that performs security scanning using ZenGuard')] +#[AsTool('zenguard_vulnerability_check', 'Tool that checks for vulnerabilities', method: 'vulnerabilityCheck')] +#[AsTool('zenguard_malware_detection', 'Tool that detects malware', method: 'malwareDetection')] +#[AsTool('zenguard_code_analysis', 'Tool that analyzes code security', method: 'codeAnalysis')] +#[AsTool('zenguard_network_scan', 'Tool that performs network scanning', method: 'networkScan')] +#[AsTool('zenguard_threat_intelligence', 'Tool that provides threat intelligence', method: 'threatIntelligence')] +#[AsTool('zenguard_compliance_check', 'Tool that checks compliance', method: 'complianceCheck')] +#[AsTool('zenguard_security_report', 'Tool that generates security reports', method: 'securityReport')] +final readonly class ZenGuard +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + private string $apiKey, + private string $baseUrl = 'https://api.zenguard.com/v1', + private array $options = [], + ) { + } + + /** + * Perform security scanning using ZenGuard. + * + * @param string $target Target to scan (URL, IP, domain) + * @param string $scanType Type of scan (web, network, code, comprehensive) + * @param array $scanOptions Additional scan options + * @param string $priority Scan priority (low, medium, high, critical) + * + * @return array{ + * success: bool, + * scan: array{ + * target: string, + * scan_id: string, + * scan_type: string, + * status: string, + * start_time: string, + * end_time: string, + * vulnerabilities: array, + * }>, + * threats: array, + * }>, + * recommendations: array, + * risk_score: float, + * }, + * priority: string, + * processingTime: float, + * error: string, + * } + */ + public function __invoke( + string $target, + string $scanType = 'comprehensive', + array $scanOptions = [], + string $priority = 'medium', + ): array { + try { + $requestData = [ + 'target' => $target, + 'scan_type' => $scanType, + 'scan_options' => $scanOptions, + 'priority' => $priority, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/scan", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $scan = $data['scan'] ?? []; + + return [ + 'success' => true, + 'scan' => [ + 'target' => $target, + 'scan_id' => $scan['scan_id'] ?? '', + 'scan_type' => $scanType, + 'status' => $scan['status'] ?? 'pending', + 'start_time' => $scan['start_time'] ?? '', + 'end_time' => $scan['end_time'] ?? '', + 'vulnerabilities' => array_map(fn ($vuln) => [ + 'id' => $vuln['id'] ?? '', + 'title' => $vuln['title'] ?? '', + 'severity' => $vuln['severity'] ?? 'low', + 'cvss_score' => $vuln['cvss_score'] ?? 0.0, + 'description' => $vuln['description'] ?? '', + 'remediation' => $vuln['remediation'] ?? '', + 'affected_components' => $vuln['affected_components'] ?? [], + ], $scan['vulnerabilities'] ?? []), + 'threats' => array_map(fn ($threat) => [ + 'type' => $threat['type'] ?? '', + 'level' => $threat['level'] ?? 'low', + 'description' => $threat['description'] ?? '', + 'indicators' => $threat['indicators'] ?? [], + ], $scan['threats'] ?? []), + 'recommendations' => $scan['recommendations'] ?? [], + 'risk_score' => $scan['risk_score'] ?? 0.0, + ], + 'priority' => $priority, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'scan' => [ + 'target' => $target, + 'scan_id' => '', + 'scan_type' => $scanType, + 'status' => 'failed', + 'start_time' => '', + 'end_time' => '', + 'vulnerabilities' => [], + 'threats' => [], + 'recommendations' => [], + 'risk_score' => 0.0, + ], + 'priority' => $priority, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Check for vulnerabilities. + * + * @param string $target Target to check + * @param array $vulnerabilityTypes Types of vulnerabilities to check + * @param string $severity Minimum severity level + * + * @return array{ + * success: bool, + * vulnerabilities: array, + * exploit_available: bool, + * patch_available: bool, + * }>, + * total_vulnerabilities: int, + * critical_count: int, + * high_count: int, + * medium_count: int, + * low_count: int, + * processingTime: float, + * error: string, + * } + */ + public function vulnerabilityCheck( + string $target, + array $vulnerabilityTypes = ['web', 'network', 'code'], + string $severity = 'low', + ): array { + try { + $requestData = [ + 'target' => $target, + 'vulnerability_types' => $vulnerabilityTypes, + 'severity' => $severity, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/vulnerability/check", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $vulnerabilities = $data['vulnerabilities'] ?? []; + + $criticalCount = 0; + $highCount = 0; + $mediumCount = 0; + $lowCount = 0; + + foreach ($vulnerabilities as $vuln) { + switch ($vuln['severity'] ?? 'low') { + case 'critical': + $criticalCount++; + break; + case 'high': + $highCount++; + break; + case 'medium': + $mediumCount++; + break; + case 'low': + default: + $lowCount++; + break; + } + } + + return [ + 'success' => true, + 'vulnerabilities' => array_map(fn ($vuln) => [ + 'id' => $vuln['id'] ?? '', + 'title' => $vuln['title'] ?? '', + 'severity' => $vuln['severity'] ?? 'low', + 'cvss_score' => $vuln['cvss_score'] ?? 0.0, + 'cve_id' => $vuln['cve_id'] ?? '', + 'description' => $vuln['description'] ?? '', + 'remediation' => $vuln['remediation'] ?? '', + 'affected_components' => $vuln['affected_components'] ?? [], + 'exploit_available' => $vuln['exploit_available'] ?? false, + 'patch_available' => $vuln['patch_available'] ?? false, + ], $vulnerabilities), + 'total_vulnerabilities' => \count($vulnerabilities), + 'critical_count' => $criticalCount, + 'high_count' => $highCount, + 'medium_count' => $mediumCount, + 'low_count' => $lowCount, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'vulnerabilities' => [], + 'total_vulnerabilities' => 0, + 'critical_count' => 0, + 'high_count' => 0, + 'medium_count' => 0, + 'low_count' => 0, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Detect malware. + * + * @param string $filePath Path to file to scan + * @param string $fileType Type of file (binary, script, document) + * @param bool $deepScan Whether to perform deep scanning + * + * @return array{ + * success: bool, + * malware_detection: array{ + * file_path: string, + * file_type: string, + * is_malicious: bool, + * threat_level: string, + * malware_family: string, + * detection_engine: string, + * signatures: array, + * behavior_analysis: array{ + * suspicious_actions: array, + * network_activity: array, + * file_operations: array, + * }, + * recommendations: array, + * }, + * deep_scan: bool, + * processingTime: float, + * error: string, + * } + */ + public function malwareDetection( + string $filePath, + string $fileType = 'binary', + bool $deepScan = false, + ): array { + try { + $requestData = [ + 'file_path' => $filePath, + 'file_type' => $fileType, + 'deep_scan' => $deepScan, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/malware/detect", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $detection = $data['malware_detection'] ?? []; + + return [ + 'success' => true, + 'malware_detection' => [ + 'file_path' => $filePath, + 'file_type' => $fileType, + 'is_malicious' => $detection['is_malicious'] ?? false, + 'threat_level' => $detection['threat_level'] ?? 'clean', + 'malware_family' => $detection['malware_family'] ?? '', + 'detection_engine' => $detection['detection_engine'] ?? '', + 'signatures' => $detection['signatures'] ?? [], + 'behavior_analysis' => [ + 'suspicious_actions' => $detection['behavior_analysis']['suspicious_actions'] ?? [], + 'network_activity' => $detection['behavior_analysis']['network_activity'] ?? [], + 'file_operations' => $detection['behavior_analysis']['file_operations'] ?? [], + ], + 'recommendations' => $detection['recommendations'] ?? [], + ], + 'deep_scan' => $deepScan, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'malware_detection' => [ + 'file_path' => $filePath, + 'file_type' => $fileType, + 'is_malicious' => false, + 'threat_level' => 'clean', + 'malware_family' => '', + 'detection_engine' => '', + 'signatures' => [], + 'behavior_analysis' => [ + 'suspicious_actions' => [], + 'network_activity' => [], + 'file_operations' => [], + ], + 'recommendations' => [], + ], + 'deep_scan' => $deepScan, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Analyze code security. + * + * @param string $code Code to analyze + * @param string $language Programming language + * @param array $analysisTypes Types of analysis to perform + * + * @return array{ + * success: bool, + * code_analysis: array{ + * code: string, + * language: string, + * security_issues: array, + * code_smells: array, + * dependencies: array, + * license: string, + * }>, + * metrics: array{ + * lines_of_code: int, + * cyclomatic_complexity: float, + * security_score: float, + * }, + * }, + * processingTime: float, + * error: string, + * } + */ + public function codeAnalysis( + string $code, + string $language, + array $analysisTypes = ['security', 'quality', 'dependencies'], + ): array { + try { + $requestData = [ + 'code' => $code, + 'language' => $language, + 'analysis_types' => $analysisTypes, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/code/analyze", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $analysis = $data['code_analysis'] ?? []; + + return [ + 'success' => true, + 'code_analysis' => [ + 'code' => $code, + 'language' => $language, + 'security_issues' => array_map(fn ($issue) => [ + 'type' => $issue['type'] ?? '', + 'severity' => $issue['severity'] ?? 'low', + 'line' => $issue['line'] ?? 0, + 'column' => $issue['column'] ?? 0, + 'description' => $issue['description'] ?? '', + 'remediation' => $issue['remediation'] ?? '', + 'cwe_id' => $issue['cwe_id'] ?? '', + ], $analysis['security_issues'] ?? []), + 'code_smells' => array_map(fn ($smell) => [ + 'type' => $smell['type'] ?? '', + 'line' => $smell['line'] ?? 0, + 'description' => $smell['description'] ?? '', + 'suggestion' => $smell['suggestion'] ?? '', + ], $analysis['code_smells'] ?? []), + 'dependencies' => array_map(fn ($dep) => [ + 'name' => $dep['name'] ?? '', + 'version' => $dep['version'] ?? '', + 'vulnerabilities' => $dep['vulnerabilities'] ?? [], + 'license' => $dep['license'] ?? '', + ], $analysis['dependencies'] ?? []), + 'metrics' => [ + 'lines_of_code' => $analysis['metrics']['lines_of_code'] ?? 0, + 'cyclomatic_complexity' => $analysis['metrics']['cyclomatic_complexity'] ?? 0.0, + 'security_score' => $analysis['metrics']['security_score'] ?? 0.0, + ], + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'code_analysis' => [ + 'code' => $code, + 'language' => $language, + 'security_issues' => [], + 'code_smells' => [], + 'dependencies' => [], + 'metrics' => [ + 'lines_of_code' => 0, + 'cyclomatic_complexity' => 0.0, + 'security_score' => 0.0, + ], + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Perform network scanning. + * + * @param string $target Network target (IP range, domain) + * @param array $ports Ports to scan + * @param string $scanType Type of scan (tcp, udp, syn) + * @param int $timeout Scan timeout in seconds + * + * @return array{ + * success: bool, + * network_scan: array{ + * target: string, + * open_ports: array, + * closed_ports: array, + * filtered_ports: array, + * services: array, + * vulnerabilities: array, + * scan_duration: float, + * total_ports_scanned: int, + * }, + * processingTime: float, + * error: string, + * } + */ + public function networkScan( + string $target, + array $ports = [22, 23, 25, 53, 80, 110, 143, 443, 993, 995], + string $scanType = 'tcp', + int $timeout = 30, + ): array { + try { + $requestData = [ + 'target' => $target, + 'ports' => $ports, + 'scan_type' => $scanType, + 'timeout' => $timeout, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/network/scan", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $scan = $data['network_scan'] ?? []; + + return [ + 'success' => true, + 'network_scan' => [ + 'target' => $target, + 'open_ports' => array_map(fn ($port) => [ + 'port' => $port['port'] ?? 0, + 'protocol' => $port['protocol'] ?? 'tcp', + 'service' => $port['service'] ?? '', + 'version' => $port['version'] ?? '', + 'banner' => $port['banner'] ?? '', + ], $scan['open_ports'] ?? []), + 'closed_ports' => $scan['closed_ports'] ?? [], + 'filtered_ports' => $scan['filtered_ports'] ?? [], + 'services' => array_map(fn ($service) => [ + 'name' => $service['name'] ?? '', + 'port' => $service['port'] ?? 0, + 'protocol' => $service['protocol'] ?? 'tcp', + 'state' => $service['state'] ?? 'unknown', + 'version' => $service['version'] ?? '', + ], $scan['services'] ?? []), + 'vulnerabilities' => array_map(fn ($vuln) => [ + 'port' => $vuln['port'] ?? 0, + 'service' => $vuln['service'] ?? '', + 'vulnerability' => $vuln['vulnerability'] ?? '', + 'severity' => $vuln['severity'] ?? 'low', + ], $scan['vulnerabilities'] ?? []), + 'scan_duration' => $scan['scan_duration'] ?? 0.0, + 'total_ports_scanned' => \count($ports), + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'network_scan' => [ + 'target' => $target, + 'open_ports' => [], + 'closed_ports' => [], + 'filtered_ports' => [], + 'services' => [], + 'vulnerabilities' => [], + 'scan_duration' => 0.0, + 'total_ports_scanned' => \count($ports), + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Provide threat intelligence. + * + * @param string $indicator Threat indicator (IP, domain, hash, URL) + * @param string $indicatorType Type of indicator + * @param string $timeframe Timeframe for intelligence + * + * @return array{ + * success: bool, + * threat_intelligence: array{ + * indicator: string, + * indicator_type: string, + * reputation: string, + * confidence: float, + * threat_actors: array, + * malware_families: array, + * attack_vectors: array, + * iocs: array, + * timeline: array, + * recommendations: array, + * }, + * timeframe: string, + * processingTime: float, + * error: string, + * } + */ + public function threatIntelligence( + string $indicator, + string $indicatorType = 'ip', + string $timeframe = '30d', + ): array { + try { + $requestData = [ + 'indicator' => $indicator, + 'indicator_type' => $indicatorType, + 'timeframe' => $timeframe, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/threat/intelligence", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $intelligence = $data['threat_intelligence'] ?? []; + + return [ + 'success' => true, + 'threat_intelligence' => [ + 'indicator' => $indicator, + 'indicator_type' => $indicatorType, + 'reputation' => $intelligence['reputation'] ?? 'unknown', + 'confidence' => $intelligence['confidence'] ?? 0.0, + 'threat_actors' => $intelligence['threat_actors'] ?? [], + 'malware_families' => $intelligence['malware_families'] ?? [], + 'attack_vectors' => $intelligence['attack_vectors'] ?? [], + 'iocs' => array_map(fn ($ioc) => [ + 'type' => $ioc['type'] ?? '', + 'value' => $ioc['value'] ?? '', + 'confidence' => $ioc['confidence'] ?? 0.0, + ], $intelligence['iocs'] ?? []), + 'timeline' => array_map(fn ($event) => [ + 'timestamp' => $event['timestamp'] ?? '', + 'event' => $event['event'] ?? '', + 'source' => $event['source'] ?? '', + ], $intelligence['timeline'] ?? []), + 'recommendations' => $intelligence['recommendations'] ?? [], + ], + 'timeframe' => $timeframe, + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'threat_intelligence' => [ + 'indicator' => $indicator, + 'indicator_type' => $indicatorType, + 'reputation' => 'unknown', + 'confidence' => 0.0, + 'threat_actors' => [], + 'malware_families' => [], + 'attack_vectors' => [], + 'iocs' => [], + 'timeline' => [], + 'recommendations' => [], + ], + 'timeframe' => $timeframe, + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Check compliance. + * + * @param string $target Target to check compliance for + * @param string $standard Compliance standard (ISO27001, SOC2, GDPR, HIPAA) + * @param array $requirements Specific requirements to check + * + * @return array{ + * success: bool, + * compliance: array{ + * target: string, + * standard: string, + * compliance_score: float, + * status: string, + * requirements: array, + * recommendations: array, + * }>, + * gaps: array, + * recommendations: array, + * next_assessment: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function complianceCheck( + string $target, + string $standard = 'ISO27001', + array $requirements = [], + ): array { + try { + $requestData = [ + 'target' => $target, + 'standard' => $standard, + 'requirements' => $requirements, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/compliance/check", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $compliance = $data['compliance'] ?? []; + + return [ + 'success' => true, + 'compliance' => [ + 'target' => $target, + 'standard' => $standard, + 'compliance_score' => $compliance['compliance_score'] ?? 0.0, + 'status' => $compliance['status'] ?? 'non-compliant', + 'requirements' => array_map(fn ($req) => [ + 'id' => $req['id'] ?? '', + 'title' => $req['title'] ?? '', + 'status' => $req['status'] ?? 'not_met', + 'description' => $req['description'] ?? '', + 'evidence' => $req['evidence'] ?? [], + 'recommendations' => $req['recommendations'] ?? [], + ], $compliance['requirements'] ?? []), + 'gaps' => array_map(fn ($gap) => [ + 'requirement_id' => $gap['requirement_id'] ?? '', + 'gap_description' => $gap['gap_description'] ?? '', + 'severity' => $gap['severity'] ?? 'low', + 'remediation' => $gap['remediation'] ?? '', + ], $compliance['gaps'] ?? []), + 'recommendations' => $compliance['recommendations'] ?? [], + 'next_assessment' => $compliance['next_assessment'] ?? '', + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'compliance' => [ + 'target' => $target, + 'standard' => $standard, + 'compliance_score' => 0.0, + 'status' => 'non-compliant', + 'requirements' => [], + 'gaps' => [], + 'recommendations' => [], + 'next_assessment' => '', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Generate security report. + * + * @param string $scanId Scan ID to generate report for + * @param string $reportType Type of report (executive, technical, detailed) + * @param string $format Report format (pdf, html, json) + * @param array $sections Sections to include in report + * + * @return array{ + * success: bool, + * report: array{ + * scan_id: string, + * report_type: string, + * format: string, + * report_url: string, + * summary: array{ + * total_vulnerabilities: int, + * critical_count: int, + * high_count: int, + * medium_count: int, + * low_count: int, + * risk_score: float, + * compliance_score: float, + * }, + * sections: array, + * generated_at: string, + * expires_at: string, + * }, + * processingTime: float, + * error: string, + * } + */ + public function securityReport( + string $scanId, + string $reportType = 'technical', + string $format = 'pdf', + array $sections = ['summary', 'vulnerabilities', 'recommendations'], + ): array { + try { + $requestData = [ + 'scan_id' => $scanId, + 'report_type' => $reportType, + 'format' => $format, + 'sections' => $sections, + ]; + + $response = $this->httpClient->request('POST', "{$this->baseUrl}/reports/generate", [ + 'headers' => [ + 'Authorization' => "Bearer {$this->apiKey}", + 'Content-Type' => 'application/json', + ], + 'json' => $requestData, + ] + $this->options); + + $data = $response->toArray(); + $report = $data['report'] ?? []; + + return [ + 'success' => true, + 'report' => [ + 'scan_id' => $scanId, + 'report_type' => $reportType, + 'format' => $format, + 'report_url' => $report['report_url'] ?? '', + 'summary' => [ + 'total_vulnerabilities' => $report['summary']['total_vulnerabilities'] ?? 0, + 'critical_count' => $report['summary']['critical_count'] ?? 0, + 'high_count' => $report['summary']['high_count'] ?? 0, + 'medium_count' => $report['summary']['medium_count'] ?? 0, + 'low_count' => $report['summary']['low_count'] ?? 0, + 'risk_score' => $report['summary']['risk_score'] ?? 0.0, + 'compliance_score' => $report['summary']['compliance_score'] ?? 0.0, + ], + 'sections' => $sections, + 'generated_at' => $report['generated_at'] ?? '', + 'expires_at' => $report['expires_at'] ?? '', + ], + 'processingTime' => $data['processing_time'] ?? 0.0, + 'error' => '', + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'report' => [ + 'scan_id' => $scanId, + 'report_type' => $reportType, + 'format' => $format, + 'report_url' => '', + 'summary' => [ + 'total_vulnerabilities' => 0, + 'critical_count' => 0, + 'high_count' => 0, + 'medium_count' => 0, + 'low_count' => 0, + 'risk_score' => 0.0, + 'compliance_score' => 0.0, + ], + 'sections' => $sections, + 'generated_at' => '', + 'expires_at' => '', + ], + 'processingTime' => 0.0, + 'error' => $e->getMessage(), + ]; + } + } +} diff --git a/src/agent/src/Toolbox/Tool/Zoom.php b/src/agent/src/Toolbox/Tool/Zoom.php new file mode 100644 index 000000000..de9ec27cc --- /dev/null +++ b/src/agent/src/Toolbox/Tool/Zoom.php @@ -0,0 +1,548 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox\Tool; + +use Symfony\AI\Agent\Toolbox\Attribute\AsTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Ledru + */ +#[AsTool('zoom_create_meeting', 'Tool that creates Zoom meetings')] +#[AsTool('zoom_list_meetings', 'Tool that lists Zoom meetings', method: 'listMeetings')] +#[AsTool('zoom_get_meeting', 'Tool that gets Zoom meeting details', method: 'getMeeting')] +#[AsTool('zoom_update_meeting', 'Tool that updates Zoom meetings', method: 'updateMeeting')] +#[AsTool('zoom_delete_meeting', 'Tool that deletes Zoom meetings', method: 'deleteMeeting')] +#[AsTool('zoom_get_meeting_participants', 'Tool that gets Zoom meeting participants', method: 'getMeetingParticipants')] +#[AsTool('zoom_create_user', 'Tool that creates Zoom users', method: 'createUser')] +final readonly class Zoom +{ + /** + * @param array $options Additional options + */ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $accessToken, + private string $apiVersion = 'v2', + private array $options = [], + ) { + } + + /** + * Create a Zoom meeting. + * + * @param string $topic Meeting topic + * @param string $startTime Meeting start time (ISO 8601 format) + * @param int $duration Meeting duration in minutes + * @param string $type Meeting type (1=instant, 2=scheduled, 3=recurring_no_fixed_time, 8=recurring_fixed_time) + * @param string $timezone Meeting timezone + * @param string $password Meeting password (optional) + * @param array $settings Meeting settings + * + * @return array{ + * id: int, + * topic: string, + * type: int, + * start_time: string, + * duration: int, + * timezone: string, + * password: string, + * join_url: string, + * start_url: string, + * created_at: string, + * settings: array{ + * host_video: bool, + * participant_video: bool, + * cn_meeting: bool, + * in_meeting: bool, + * join_before_host: bool, + * jbh_time: int, + * mute_upon_entry: bool, + * watermark: bool, + * use_pmi: bool, + * approval_type: int, + * audio: string, + * auto_recording: string, + * enforce_login: bool, + * enforce_login_domains: string, + * alternative_hosts: string, + * close_registration: bool, + * show_share_button: bool, + * allow_multiple_devices: bool, + * registrants_confirmation_email: bool, + * waiting_room: bool, + * request_permission_to_unmute_participants: bool, + * global_dial_in_countries: array, + * registrants_email_notification: bool, + * }, + * }|string + */ + public function __invoke( + string $topic, + string $startTime = '', + int $duration = 60, + int $type = 2, + string $timezone = 'UTC', + string $password = '', + array $settings = [], + ): array|string { + try { + $payload = [ + 'topic' => $topic, + 'type' => $type, + 'duration' => $duration, + 'timezone' => $timezone, + ]; + + if ($startTime) { + $payload['start_time'] = $startTime; + } + + if ($password) { + $payload['password'] = $password; + } + + if (!empty($settings)) { + $payload['settings'] = $settings; + } + + $response = $this->httpClient->request('POST', "https://api.zoom.us/{$this->apiVersion}/users/me/meetings", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['code'])) { + return 'Error creating meeting: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'topic' => $data['topic'], + 'type' => $data['type'], + 'start_time' => $data['start_time'], + 'duration' => $data['duration'], + 'timezone' => $data['timezone'], + 'password' => $data['password'] ?? '', + 'join_url' => $data['join_url'], + 'start_url' => $data['start_url'], + 'created_at' => $data['created_at'], + 'settings' => [ + 'host_video' => $data['settings']['host_video'] ?? false, + 'participant_video' => $data['settings']['participant_video'] ?? false, + 'cn_meeting' => $data['settings']['cn_meeting'] ?? false, + 'in_meeting' => $data['settings']['in_meeting'] ?? false, + 'join_before_host' => $data['settings']['join_before_host'] ?? false, + 'jbh_time' => $data['settings']['jbh_time'] ?? 0, + 'mute_upon_entry' => $data['settings']['mute_upon_entry'] ?? false, + 'watermark' => $data['settings']['watermark'] ?? false, + 'use_pmi' => $data['settings']['use_pmi'] ?? false, + 'approval_type' => $data['settings']['approval_type'] ?? 2, + 'audio' => $data['settings']['audio'] ?? 'both', + 'auto_recording' => $data['settings']['auto_recording'] ?? 'local', + 'enforce_login' => $data['settings']['enforce_login'] ?? false, + 'enforce_login_domains' => $data['settings']['enforce_login_domains'] ?? '', + 'alternative_hosts' => $data['settings']['alternative_hosts'] ?? '', + 'close_registration' => $data['settings']['close_registration'] ?? false, + 'show_share_button' => $data['settings']['show_share_button'] ?? false, + 'allow_multiple_devices' => $data['settings']['allow_multiple_devices'] ?? false, + 'registrants_confirmation_email' => $data['settings']['registrants_confirmation_email'] ?? false, + 'waiting_room' => $data['settings']['waiting_room'] ?? false, + 'request_permission_to_unmute_participants' => $data['settings']['request_permission_to_unmute_participants'] ?? false, + 'global_dial_in_countries' => $data['settings']['global_dial_in_countries'] ?? [], + 'registrants_email_notification' => $data['settings']['registrants_email_notification'] ?? false, + ], + ]; + } catch (\Exception $e) { + return 'Error creating meeting: '.$e->getMessage(); + } + } + + /** + * List Zoom meetings. + * + * @param string $type Meeting type filter (live, upcoming, past) + * @param int $pageSize Number of meetings per page + * @param int $pageNumber Page number + * + * @return array + */ + public function listMeetings( + string $type = 'upcoming', + int $pageSize = 30, + int $pageNumber = 1, + ): array { + try { + $params = [ + 'type' => $type, + 'page_size' => min(max($pageSize, 1), 300), + 'page_number' => max($pageNumber, 1), + ]; + + $response = $this->httpClient->request('GET', "https://api.zoom.us/{$this->apiVersion}/users/me/meetings", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => $params, + ]); + + $data = $response->toArray(); + + if (!isset($data['meetings'])) { + return []; + } + + $meetings = []; + foreach ($data['meetings'] as $meeting) { + $meetings[] = [ + 'uuid' => $meeting['uuid'], + 'id' => $meeting['id'], + 'host_id' => $meeting['host_id'], + 'topic' => $meeting['topic'], + 'type' => $meeting['type'], + 'start_time' => $meeting['start_time'], + 'duration' => $meeting['duration'], + 'timezone' => $meeting['timezone'], + 'created_at' => $meeting['created_at'], + 'join_url' => $meeting['join_url'], + ]; + } + + return $meetings; + } catch (\Exception $e) { + return []; + } + } + + /** + * Get Zoom meeting details. + * + * @param string $meetingId Meeting ID or UUID + * + * @return array{ + * id: int, + * topic: string, + * type: int, + * start_time: string, + * duration: int, + * timezone: string, + * password: string, + * join_url: string, + * start_url: string, + * created_at: string, + * host_id: string, + * settings: array, + * recurrence: array|null, + * }|string + */ + public function getMeeting(string $meetingId): array|string + { + try { + $response = $this->httpClient->request('GET', "https://api.zoom.us/{$this->apiVersion}/meetings/{$meetingId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + ]); + + $data = $response->toArray(); + + if (isset($data['code'])) { + return 'Error getting meeting: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'id' => $data['id'], + 'topic' => $data['topic'], + 'type' => $data['type'], + 'start_time' => $data['start_time'], + 'duration' => $data['duration'], + 'timezone' => $data['timezone'], + 'password' => $data['password'] ?? '', + 'join_url' => $data['join_url'], + 'start_url' => $data['start_url'], + 'created_at' => $data['created_at'], + 'host_id' => $data['host_id'], + 'settings' => $data['settings'] ?? [], + 'recurrence' => $data['recurrence'] ?? null, + ]; + } catch (\Exception $e) { + return 'Error getting meeting: '.$e->getMessage(); + } + } + + /** + * Update a Zoom meeting. + * + * @param string $meetingId Meeting ID or UUID + * @param array $updates Fields to update + */ + public function updateMeeting(string $meetingId, array $updates): string + { + try { + $response = $this->httpClient->request('PATCH', "https://api.zoom.us/{$this->apiVersion}/meetings/{$meetingId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $updates, + ]); + + if (204 === $response->getStatusCode()) { + return "Meeting {$meetingId} updated successfully"; + } else { + $data = $response->toArray(); + + return 'Error updating meeting: '.($data['message'] ?? 'Unknown error'); + } + } catch (\Exception $e) { + return 'Error updating meeting: '.$e->getMessage(); + } + } + + /** + * Delete a Zoom meeting. + * + * @param string $meetingId Meeting ID or UUID + * @param string $scheduleFor Schedule deletion for (delete, cancel) + * @param bool $cancelMeetingReminder Cancel meeting reminder + */ + public function deleteMeeting( + string $meetingId, + string $scheduleFor = 'delete', + bool $cancelMeetingReminder = false, + ): string { + try { + $params = [ + 'schedule_for_reminder' => $cancelMeetingReminder, + ]; + + if ('cancel' === $scheduleFor) { + $params['cancel_meeting_reminder'] = true; + } + + $response = $this->httpClient->request('DELETE', "https://api.zoom.us/{$this->apiVersion}/meetings/{$meetingId}", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => $params, + ]); + + if (204 === $response->getStatusCode()) { + return "Meeting {$meetingId} deleted successfully"; + } else { + $data = $response->toArray(); + + return 'Error deleting meeting: '.($data['message'] ?? 'Unknown error'); + } + } catch (\Exception $e) { + return 'Error deleting meeting: '.$e->getMessage(); + } + } + + /** + * Get Zoom meeting participants. + * + * @param string $meetingId Meeting ID or UUID + * @param int $pageSize Number of participants per page + * @param string $nextPageToken Next page token for pagination + * + * @return array{ + * page_count: int, + * page_size: int, + * total_records: int, + * next_page_token: string, + * participants: array, + * }|string + */ + public function getMeetingParticipants( + string $meetingId, + int $pageSize = 30, + string $nextPageToken = '', + ): array|string { + try { + $params = [ + 'page_size' => min(max($pageSize, 1), 300), + ]; + + if ($nextPageToken) { + $params['next_page_token'] = $nextPageToken; + } + + $response = $this->httpClient->request('GET', "https://api.zoom.us/{$this->apiVersion}/meetings/{$meetingId}/participants", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + ], + 'query' => $params, + ]); + + $data = $response->toArray(); + + if (isset($data['code'])) { + return 'Error getting meeting participants: '.($data['message'] ?? 'Unknown error'); + } + + return [ + 'page_count' => $data['page_count'], + 'page_size' => $data['page_size'], + 'total_records' => $data['total_records'], + 'next_page_token' => $data['next_page_token'] ?? '', + 'participants' => array_map(fn ($participant) => [ + 'id' => $participant['id'], + 'user_id' => $participant['user_id'], + 'name' => $participant['name'], + 'user_email' => $participant['user_email'], + 'join_time' => $participant['join_time'], + 'leave_time' => $participant['leave_time'] ?? '', + 'duration' => $participant['duration'], + 'failover' => $participant['failover'] ?? false, + 'status' => $participant['status'], + 'customer_key' => $participant['customer_key'] ?? '', + 'registrant_id' => $participant['registrant_id'] ?? '', + ], $data['participants']), + ]; + } catch (\Exception $e) { + return 'Error getting meeting participants: '.$e->getMessage(); + } + } + + /** + * Create a Zoom user. + * + * @param string $email User email address + * @param string $firstName User first name + * @param string $lastName User last name + * @param string $type User type (1=basic, 2=licensed, 3=on-prem) + * @param string $password User password (optional) + * + * @return array{ + * id: string, + * first_name: string, + * last_name: string, + * display_name: string, + * email: string, + * type: int, + * role_name: string, + * pmi: int, + * use_pmi: bool, + * personal_meeting_url: string, + * timezone: string, + * verified: int, + * dept: string, + * created_at: string, + * last_login_time: string, + * last_client_version: string, + * language: string, + * phone_country: string, + * phone_number: string, + * status: string, + * jid: string, + * job_title: string, + * company: string, + * location: string, + * }|string + */ + public function createUser( + string $email, + string $firstName, + string $lastName, + int $type = 1, + string $password = '', + ): array|string { + try { + $payload = [ + 'action' => 'create', + 'user_info' => [ + 'email' => $email, + 'first_name' => $firstName, + 'last_name' => $lastName, + 'type' => $type, + ], + ]; + + if ($password) { + $payload['user_info']['password'] = $password; + } + + $response = $this->httpClient->request('POST', "https://api.zoom.us/{$this->apiVersion}/users", [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->accessToken, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + $data = $response->toArray(); + + if (isset($data['code'])) { + return 'Error creating user: '.($data['message'] ?? 'Unknown error'); + } + + $user = $data['user_info']; + + return [ + 'id' => $user['id'], + 'first_name' => $user['first_name'], + 'last_name' => $user['last_name'], + 'display_name' => $user['display_name'], + 'email' => $user['email'], + 'type' => $user['type'], + 'role_name' => $user['role_name'], + 'pmi' => $user['pmi'], + 'use_pmi' => $user['use_pmi'], + 'personal_meeting_url' => $user['personal_meeting_url'], + 'timezone' => $user['timezone'], + 'verified' => $user['verified'], + 'dept' => $user['dept'] ?? '', + 'created_at' => $user['created_at'], + 'last_login_time' => $user['last_login_time'] ?? '', + 'last_client_version' => $user['last_client_version'] ?? '', + 'language' => $user['language'], + 'phone_country' => $user['phone_country'] ?? '', + 'phone_number' => $user['phone_number'] ?? '', + 'status' => $user['status'], + 'jid' => $user['jid'], + 'job_title' => $user['job_title'] ?? '', + 'company' => $user['company'] ?? '', + 'location' => $user['location'] ?? '', + ]; + } catch (\Exception $e) { + return 'Error creating user: '.$e->getMessage(); + } + } +}