diff --git a/docs/bundles/ai-bundle.rst b/docs/bundles/ai-bundle.rst index ca549b530..8ad5b3c56 100644 --- a/docs/bundles/ai-bundle.rst +++ b/docs/bundles/ai-bundle.rst @@ -785,15 +785,15 @@ To use existing tools, you can register them as a service: Symfony\AI\Agent\Toolbox\Tool\SerpApi: $apiKey: '%env(SERP_API_KEY)%' Symfony\AI\Agent\Toolbox\Tool\SimilaritySearch: ~ - Symfony\AI\Agent\Toolbox\Tool\Tavily: - $apiKey: '%env(TAVILY_API_KEY)%' Symfony\AI\Agent\Toolbox\Tool\Wikipedia: ~ Symfony\AI\Agent\Toolbox\Tool\YouTubeTranscriber: ~ Symfony\AI\Agent\Toolbox\Tool\Firecrawl: $endpoint: '%env(FIRECRAWL_ENDPOINT)%' $apiKey: '%env(FIRECRAWL_API_KEY)%' - Symfony\AI\Agent\Toolbox\Tool\Brave: + Symfony\AI\Agent\Bridge\Brave\Brave: $apiKey: '%env(BRAVE_API_KEY)%' + Symfony\AI\Agent\Bridge\Tavily\Tavily: + $apiKey: '%env(TAVILY_API_KEY)%' Custom tools can be registered by using the :class:`Symfony\\AI\\Agent\\Toolbox\\Attribute\\AsTool` attribute:: diff --git a/examples/composer.json b/examples/composer.json index ce40a4c1e..4ed101ed3 100644 --- a/examples/composer.json +++ b/examples/composer.json @@ -18,10 +18,11 @@ "probots-io/pinecone-php": "^1.0", "psr/http-factory-implementation": "*", "symfony/ai-agent": "@dev", + "symfony/ai-brave-tool": "@dev", "symfony/ai-chat": "@dev", "symfony/ai-platform": "@dev", "symfony/ai-store": "@dev", - "symfony/ai-brave-tool": "@dev", + "symfony/ai-tavily-tool": "@dev", "symfony/cache": "^7.3|^8.0", "symfony/clock": "^7.3|^8.0", "symfony/console": "^7.3|^8.0", diff --git a/examples/openai/agent-stream-sources.php b/examples/openai/agent-stream-sources.php index 187243a7c..00610bd1d 100644 --- a/examples/openai/agent-stream-sources.php +++ b/examples/openai/agent-stream-sources.php @@ -10,9 +10,9 @@ */ use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\Bridge\Tavily\Tavily; use Symfony\AI\Agent\Toolbox\AgentProcessor; use Symfony\AI\Agent\Toolbox\Tool\Clock; -use Symfony\AI\Agent\Toolbox\Tool\Tavily; use Symfony\AI\Agent\Toolbox\Toolbox; use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; use Symfony\AI\Platform\Message\Message; diff --git a/examples/toolbox/tavily.php b/examples/toolbox/tavily.php index f31d4729b..115ab40b1 100644 --- a/examples/toolbox/tavily.php +++ b/examples/toolbox/tavily.php @@ -10,9 +10,9 @@ */ use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\Bridge\Tavily\Tavily; use Symfony\AI\Agent\Toolbox\AgentProcessor; use Symfony\AI\Agent\Toolbox\Tool\Clock; -use Symfony\AI\Agent\Toolbox\Tool\Tavily; use Symfony\AI\Agent\Toolbox\Toolbox; use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; use Symfony\AI\Platform\Message\Message; diff --git a/src/agent/CHANGELOG.md b/src/agent/CHANGELOG.md index 7fabac78c..7a7144e97 100644 --- a/src/agent/CHANGELOG.md +++ b/src/agent/CHANGELOG.md @@ -25,14 +25,15 @@ CHANGELOG - `SimilaritySearch` for RAG/vector store searches - `Agent` allowing agents to use other agents as tools - `Clock` for current date/time - - `Brave` for web search integration - `Crawler` for web page crawling - `Mapbox` for geocoding addresses to coordinates and reverse geocoding - `OpenMeteo` for weather information - `SerpApi` for search engine results - - `Tavily` for AI-powered search - `Wikipedia` for Wikipedia content retrieval - `YouTubeTranscriber` for YouTube video transcription + * Add bridges: + - `Brave` for web search integration (`symfony/ai-brave-tool`) + - `Tavily` for AI-powered search (`symfony/ai-tavily-tool`) * Add structured output support: - PHP class output with automatic conversion from LLM responses - Array structure output with JSON schema validation diff --git a/src/agent/src/Bridge/Tavily/.gitattributes b/src/agent/src/Bridge/Tavily/.gitattributes new file mode 100644 index 000000000..14c3c3594 --- /dev/null +++ b/src/agent/src/Bridge/Tavily/.gitattributes @@ -0,0 +1,3 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.git* export-ignore diff --git a/src/agent/src/Bridge/Tavily/.github/PULL_REQUEST_TEMPLATE.md b/src/agent/src/Bridge/Tavily/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..fcb87228a --- /dev/null +++ b/src/agent/src/Bridge/Tavily/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ai + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/agent/src/Bridge/Tavily/.github/close-pull-request.yml b/src/agent/src/Bridge/Tavily/.github/close-pull-request.yml new file mode 100644 index 000000000..bb5a02835 --- /dev/null +++ b/src/agent/src/Bridge/Tavily/.github/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ai + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/agent/src/Bridge/Tavily/.gitignore b/src/agent/src/Bridge/Tavily/.gitignore new file mode 100644 index 000000000..76367ee5b --- /dev/null +++ b/src/agent/src/Bridge/Tavily/.gitignore @@ -0,0 +1,4 @@ +vendor/ +composer.lock +phpunit.xml +.phpunit.result.cache diff --git a/src/agent/src/Bridge/Tavily/CHANGELOG.md b/src/agent/src/Bridge/Tavily/CHANGELOG.md new file mode 100644 index 000000000..0915f3546 --- /dev/null +++ b/src/agent/src/Bridge/Tavily/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +0.1 +--- + + * Add the bridge diff --git a/src/agent/src/Bridge/Tavily/LICENSE b/src/agent/src/Bridge/Tavily/LICENSE new file mode 100644 index 000000000..bc38d714e --- /dev/null +++ b/src/agent/src/Bridge/Tavily/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/agent/src/Toolbox/Tool/Tavily.php b/src/agent/src/Bridge/Tavily/Tavily.php similarity index 98% rename from src/agent/src/Toolbox/Tool/Tavily.php rename to src/agent/src/Bridge/Tavily/Tavily.php index cdf150b77..c22ae90ae 100644 --- a/src/agent/src/Toolbox/Tool/Tavily.php +++ b/src/agent/src/Bridge/Tavily/Tavily.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Agent\Toolbox\Tool; +namespace Symfony\AI\Agent\Bridge\Tavily; use Symfony\AI\Agent\Toolbox\Attribute\AsTool; use Symfony\AI\Agent\Toolbox\Source\HasSourcesInterface; diff --git a/src/agent/src/Bridge/Tavily/Tests/TavilyTest.php b/src/agent/src/Bridge/Tavily/Tests/TavilyTest.php new file mode 100644 index 000000000..afc7c96cd --- /dev/null +++ b/src/agent/src/Bridge/Tavily/Tests/TavilyTest.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Bridge\Tavily\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Bridge\Tavily\Tavily; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +/** + * @author Oskar Stark + */ +final class TavilyTest extends TestCase +{ + public function testSearchReturnsResults() + { + $result = JsonMockResponse::fromFile(__DIR__.'/fixtures/search-results.json'); + $httpClient = new MockHttpClient($result); + $tavily = new Tavily($httpClient, 'test-api-key'); + + $response = $tavily->search('latest AI news'); + + $this->assertStringContainsString('results', $response); + } + + public function testSearchPassesCorrectParameters() + { + $result = JsonMockResponse::fromFile(__DIR__.'/fixtures/search-results.json'); + $httpClient = new MockHttpClient($result); + $tavily = new Tavily($httpClient, 'test-api-key', ['include_images' => true]); + + $tavily->search('test query'); + + $requestUrl = $result->getRequestUrl(); + $this->assertSame('https://api.tavily.com/search', $requestUrl); + + $requestOptions = $result->getRequestOptions(); + $this->assertArrayHasKey('body', $requestOptions); + $body = json_decode($requestOptions['body'], true); + $this->assertSame('test query', $body['query']); + $this->assertSame('test-api-key', $body['api_key']); + $this->assertTrue($body['include_images']); + } + + public function testSearchAddsSourcesFromResults() + { + $result = JsonMockResponse::fromFile(__DIR__.'/fixtures/search-results.json'); + $httpClient = new MockHttpClient($result); + $tavily = new Tavily($httpClient, 'test-api-key'); + + $tavily->search('test query'); + + $sources = $tavily->getSourceMap()->getSources(); + $this->assertCount(2, $sources); + $this->assertSame('AI breakthrough announced', $sources[0]->getName()); + $this->assertSame('https://example.com/ai-news', $sources[0]->getReference()); + } + + public function testExtractReturnsResults() + { + $result = JsonMockResponse::fromFile(__DIR__.'/fixtures/extract-results.json'); + $httpClient = new MockHttpClient($result); + $tavily = new Tavily($httpClient, 'test-api-key'); + + $response = $tavily->extract(['https://example.com/article']); + + $this->assertStringContainsString('results', $response); + } + + public function testExtractPassesCorrectParameters() + { + $result = JsonMockResponse::fromFile(__DIR__.'/fixtures/extract-results.json'); + $httpClient = new MockHttpClient($result); + $tavily = new Tavily($httpClient, 'test-api-key'); + + $urls = ['https://example.com/article1', 'https://example.com/article2']; + $tavily->extract($urls); + + $requestUrl = $result->getRequestUrl(); + $this->assertSame('https://api.tavily.com/extract', $requestUrl); + + $requestOptions = $result->getRequestOptions(); + $this->assertArrayHasKey('body', $requestOptions); + $body = json_decode($requestOptions['body'], true); + $this->assertSame($urls, $body['urls']); + $this->assertSame('test-api-key', $body['api_key']); + } + + public function testExtractAddsSourcesFromResults() + { + $result = JsonMockResponse::fromFile(__DIR__.'/fixtures/extract-results.json'); + $httpClient = new MockHttpClient($result); + $tavily = new Tavily($httpClient, 'test-api-key'); + + $tavily->extract(['https://example.com/article']); + + $sources = $tavily->getSourceMap()->getSources(); + $this->assertCount(1, $sources); + $this->assertSame('Example Article', $sources[0]->getName()); + $this->assertSame('https://example.com/article', $sources[0]->getReference()); + } + + public function testHandlesEmptySearchResults() + { + $httpClient = new MockHttpClient(new JsonMockResponse(['results' => []])); + $tavily = new Tavily($httpClient, 'test-api-key'); + + $tavily->search('query with no results'); + + $sources = $tavily->getSourceMap()->getSources(); + $this->assertEmpty($sources); + } + + public function testHandlesEmptyExtractResults() + { + $httpClient = new MockHttpClient(new JsonMockResponse(['results' => []])); + $tavily = new Tavily($httpClient, 'test-api-key'); + + $tavily->extract(['https://nonexistent.com']); + + $sources = $tavily->getSourceMap()->getSources(); + $this->assertEmpty($sources); + } +} diff --git a/src/agent/src/Bridge/Tavily/Tests/fixtures/extract-results.json b/src/agent/src/Bridge/Tavily/Tests/fixtures/extract-results.json new file mode 100644 index 000000000..f10f9b4a2 --- /dev/null +++ b/src/agent/src/Bridge/Tavily/Tests/fixtures/extract-results.json @@ -0,0 +1,11 @@ +{ + "results": [ + { + "title": "Example Article", + "url": "https://example.com/article", + "raw_content": "This is the full content of the article that was extracted from the webpage. It contains detailed information about the topic.", + "score": 1.0 + } + ], + "response_time": 0.89 +} diff --git a/src/agent/src/Bridge/Tavily/Tests/fixtures/search-results.json b/src/agent/src/Bridge/Tavily/Tests/fixtures/search-results.json new file mode 100644 index 000000000..d6ad37b83 --- /dev/null +++ b/src/agent/src/Bridge/Tavily/Tests/fixtures/search-results.json @@ -0,0 +1,21 @@ +{ + "query": "latest AI news", + "results": [ + { + "title": "AI breakthrough announced", + "url": "https://example.com/ai-news", + "content": "Major AI company announces breakthrough in language models.", + "raw_content": "Major AI company announces breakthrough in language models with new architecture that improves efficiency.", + "score": 0.95 + }, + { + "title": "Machine Learning Advances", + "url": "https://example.com/ml-advances", + "content": "New research in machine learning shows promise.", + "raw_content": "New research in machine learning shows promise for solving complex problems in various domains.", + "score": 0.87 + } + ], + "images": [], + "response_time": 1.23 +} diff --git a/src/agent/src/Bridge/Tavily/composer.json b/src/agent/src/Bridge/Tavily/composer.json new file mode 100644 index 000000000..d745c9afe --- /dev/null +++ b/src/agent/src/Bridge/Tavily/composer.json @@ -0,0 +1,45 @@ +{ + "name": "symfony/ai-tavily-tool", + "description": "Tavily AI tool bridge for Symfony applications.", + "license": "MIT", + "type": "library", + "keywords": ["ai", "bridge", "tavily", "agent", "tool"], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + } + ], + "require": { + "php": ">=8.2", + "symfony/ai-agent": "@dev", + "symfony/http-client": "^7.3|^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.13" + }, + "autoload": { + "psr-4": { + "Symfony\\AI\\Agent\\Bridge\\Tavily\\": "" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\Agent\\Bridge\\Tavily\\Tests\\": "Tests/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "thanks": { + "name": "symfony/ai", + "url": "https://github.com/symfony/ai" + } + }, + "minimum-stability": "dev" +} diff --git a/src/agent/src/Bridge/Tavily/phpunit.xml.dist b/src/agent/src/Bridge/Tavily/phpunit.xml.dist new file mode 100644 index 000000000..6dc31c713 --- /dev/null +++ b/src/agent/src/Bridge/Tavily/phpunit.xml.dist @@ -0,0 +1,32 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Resources + ./Tests + ./vendor + + +