diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cc2f6a5 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Workbench application +APP_NAME=Toolkit +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost + +DB_CONNECTION=testing + +# Tavily API key (https://app.tavily.com) +TAVILY_API_KEY= + +OPENAI_API_KEY= diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a996363..8fc2452 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,7 +24,7 @@ jobs: uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 with: php-version: ${{ matrix.php }} - extensions: dom, mbstring, zip, fileinfo + extensions: dom, mbstring, zip, fileinfo, pdo_sqlite coverage: pcov - name: Get Composer cache directory diff --git a/.gitignore b/.gitignore index 402ac34..44405e7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,10 @@ /phpunit.xml /vendor/ /.idea/ +/.env +/workbench/database/*.sqlite *.swp *.swo .deepsec/ docs/node_modules/ +.claude diff --git a/README.md b/README.md index b5ac9ca..993dddb 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,17 @@ Toolkit is a community catalog of reusable AI tools for the [Laravel AI SDK](htt > Requires PHP 8.4+ and `laravel/ai`. +## Available Tools + +| Tool | Description | +|------|-------------| +| `CalculatorTool` | Evaluate mathematical expressions with perfect accuracy. Supports `+`, `-`, `*`, `/`, `%`, `^`, parentheses, and decimals. | +| `DatabaseQueryTool` | Run read-only `SELECT` queries against your Laravel database and return results as JSON. | +| `TavilySearch` | Search the web for real-time information using [Tavily](https://tavily.com). | +| `TavilyExtract` | Extract clean, structured content from URLs. | +| `TavilyCrawl` | Intelligently crawl a website and extract content. | +| `TavilyMap` | Discover and map a website's structure. | + ## Official Documentation Documentation for Toolkit can be found on the [documentation site](https://toolkit.shipfastlabs.com). diff --git a/composer.json b/composer.json index 0133e27..d4c5025 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,8 @@ "replace": { "shipfastlabs/toolkit-calculator": "self.version", "shipfastlabs/toolkit-database": "self.version", - "shipfastlabs/toolkit-stub": "self.version" + "shipfastlabs/toolkit-stub": "self.version", + "shipfastlabs/toolkit-tavily": "self.version" }, "minimum-stability": "dev", "prefer-stable": true, @@ -41,14 +42,19 @@ "psr-4": { "Shipfastlabs\\Toolkit\\": "src/", "Shipfastlabs\\Toolkit\\Calculator\\": "src/Calculator/src/", - "Shipfastlabs\\Toolkit\\Database\\": "src/Database/src/" + "Shipfastlabs\\Toolkit\\Database\\": "src/Database/src/", + "Shipfastlabs\\Toolkit\\Tavily\\": "src/Tavily/src/" } }, "autoload-dev": { "psr-4": { "Shipfastlabs\\Toolkit\\Calculator\\Tests\\": "src/Calculator/tests/", "Shipfastlabs\\Toolkit\\Database\\Tests\\": "src/Database/tests/", - "Shipfastlabs\\Toolkit\\Tests\\": "tests/" + "Shipfastlabs\\Toolkit\\Tavily\\Tests\\": "src/Tavily/tests/", + "Shipfastlabs\\Toolkit\\Tests\\": "tests/", + "Workbench\\App\\": "workbench/app/", + "Workbench\\Database\\Factories\\": "workbench/database/factories/", + "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" } }, "config": { @@ -90,6 +96,18 @@ "@mirrors:create", "@split", "@release" + ], + "post-autoload-dump": [ + "@clear", + "@prepare" + ], + "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "build": "@php vendor/bin/testbench workbench:build --ansi", + "serve": [ + "Composer\\Config::disableProcessTimeout", + "@build", + "@php vendor/bin/testbench serve --ansi" ] }, "scripts-descriptions": { @@ -102,4 +120,4 @@ "release": "Tag and release the tools changed in the latest commit.", "publish": "Full release: create mirrors, split, then release." } -} +} \ No newline at end of file diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 84c771f..3b68fba 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,81 +1,148 @@ -import { defineConfig } from 'vitepress' -import llmstxt from 'vitepress-plugin-llms' +import { defineConfig } from "vitepress"; +import llmstxt from "vitepress-plugin-llms"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; -// https://vitepress.dev/reference/site-config -export default defineConfig({ - title: 'Toolkit by Ship Fast Labs', - description: 'A community catalog of reusable AI tools for the Laravel AI SDK.', - cleanUrls: true, - lastUpdated: true, +// Auto-generate the Tools sidebar from docs/tools/*.md so new tools appear with +// no manual config edits. The markdown is generated by tools/sync-docs.sh, which +// stays the single source of truth. Title comes from each file's first H1, +// falling back to a capitalized slug. +const toolsDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../tools", +); - // Generate llms.txt, llms-full.txt and per-page markdown for LLM consumption. - vite: { - plugins: [ - llmstxt({ - title: 'shipfastlabs/toolkit', - description: 'A community catalog of reusable AI tools for the Laravel AI SDK.', - ignoreFiles: ['README.md'], - }), - ], - }, +function toolItems() { + return fs + .readdirSync(toolsDir) + .filter((file) => file.endsWith(".md")) + .map((file) => { + const slug = file.replace(/\.md$/, ""); + const contents = fs.readFileSync( + path.join(toolsDir, file), + "utf-8", + ); + const heading = contents.match(/^#\s+(.+)$/m)?.[1].trim(); + const text = heading ?? slug.charAt(0).toUpperCase() + slug.slice(1); + return { text, link: `/tools/${slug}` }; + }) + .sort((a, b) => a.text.localeCompare(b.text)); +} - // Dark-only design (hides the light/dark toggle). - appearance: 'force-dark', +const tools = toolItems(); - head: [ - ['link', { rel: 'preconnect', href: 'https://fonts.googleapis.com' }], - ['link', { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' }], - ['link', { - rel: 'stylesheet', - href: 'https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap', - }], +// https://vitepress.dev/reference/site-config +export default defineConfig({ + title: "Toolkit by Ship Fast Labs", + description: + "A community catalog of reusable AI tools for the Laravel AI SDK.", + cleanUrls: true, + lastUpdated: true, - // Open Graph / Twitter card (image lives at docs/public/og.png). - ['meta', { property: 'og:type', content: 'website' }], - ['meta', { property: 'og:title', content: 'Toolkit by Ship Fast Labs' }], - ['meta', { property: 'og:description', content: 'Reusable AI tools for the Laravel AI SDK.' }], - ['meta', { property: 'og:url', content: 'https://toolkit.shipfastlabs.com/' }], - ['meta', { property: 'og:image', content: 'https://toolkit.shipfastlabs.com/og.png' }], - ['meta', { name: 'twitter:card', content: 'summary_large_image' }], - ['meta', { name: 'twitter:image', content: 'https://toolkit.shipfastlabs.com/og.png' }], - ], + // Generate llms.txt, llms-full.txt and per-page markdown for LLM consumption. + vite: { + plugins: [ + llmstxt({ + title: "shipfastlabs/toolkit", + description: + "A community catalog of reusable AI tools for the Laravel AI SDK.", + ignoreFiles: ["README.md"], + }), + ], + }, - themeConfig: { - logo: '/logo.svg', + // Dark-only design (hides the light/dark toggle). + appearance: "force-dark", - nav: [ - { text: 'Guide', link: '/guide/getting-started' }, - { text: 'Tools', link: '/tools/calculator' }, - ], + head: [ + ["link", { rel: "preconnect", href: "https://fonts.googleapis.com" }], + [ + "link", + { + rel: "preconnect", + href: "https://fonts.gstatic.com", + crossorigin: "", + }, + ], + [ + "link", + { + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap", + }, + ], - sidebar: [ - { - text: 'Guide', - items: [ - { text: 'Getting Started', link: '/guide/getting-started' }, - { text: 'Contributing', link: '/guide/contributing' }, + // Open Graph / Twitter card (image lives at docs/public/og.png). + ["meta", { property: "og:type", content: "website" }], + [ + "meta", + { property: "og:title", content: "Toolkit by Ship Fast Labs" }, + ], + [ + "meta", + { + property: "og:description", + content: "Reusable AI tools for the Laravel AI SDK.", + }, ], - }, - { - text: 'Tools', - items: [ - { text: 'Calculator', link: '/tools/calculator' }, - { text: 'Database', link: '/tools/database' }, + [ + "meta", + { + property: "og:url", + content: "https://toolkit.shipfastlabs.com/", + }, + ], + [ + "meta", + { + property: "og:image", + content: "https://toolkit.shipfastlabs.com/og.png", + }, + ], + ["meta", { name: "twitter:card", content: "summary_large_image" }], + [ + "meta", + { + name: "twitter:image", + content: "https://toolkit.shipfastlabs.com/og.png", + }, ], - }, ], - socialLinks: [ - { icon: 'github', link: 'https://github.com/shipfastlabs/toolkit' }, - ], + themeConfig: { + logo: "/logo.svg", - search: { - provider: 'local', - }, + nav: [ + { text: "Guide", link: "/guide/getting-started" }, + { text: "Tools", link: tools[0]?.link ?? "/tools/calculator" }, + ], + + sidebar: [ + { + text: "Guide", + items: [ + { text: "Getting Started", link: "/guide/getting-started" }, + { text: "Contributing", link: "/guide/contributing" }, + ], + }, + { + text: "Tools", + items: tools, + }, + ], + + socialLinks: [ + { icon: "github", link: "https://github.com/shipfastlabs/toolkit" }, + ], + + search: { + provider: "local", + }, - footer: { - message: 'Released under the MIT License.', - copyright: 'Copyright © 2026 Shipfastlabs', + footer: { + message: "Released under the MIT License.", + copyright: "Copyright © 2026 Shipfastlabs", + }, }, - }, -}) +}); diff --git a/docs/tools/tavily.md b/docs/tools/tavily.md new file mode 100644 index 0000000..68aadcb --- /dev/null +++ b/docs/tools/tavily.md @@ -0,0 +1,205 @@ +# Tavily + +> Tavily tools for the Laravel AI SDK - Search, Extract, Crawl, and Map + +Part of the [shipfastlabs/toolkit](https://github.com/shipfastlabs/toolkit) catalog of reusable AI tools for the Laravel AI SDK. + +## Installation + +```bash +composer require shipfastlabs/toolkit-tavily +``` + +## Usage + +Register every Tavily tool at once with the `Tavily` helper: + +```php +use Shipfastlabs\Toolkit\Tavily\Tavily; + +$tools = Tavily::all(); // Collection +``` + +Or add individual tools to an agent's `tools()`: + +```php +use Shipfastlabs\Toolkit\Tavily\TavilySearch; +use Shipfastlabs\Toolkit\Tavily\TavilyExtract; +use Shipfastlabs\Toolkit\Tavily\TavilyCrawl; +use Shipfastlabs\Toolkit\Tavily\TavilyMap; + +$tools = [ + new TavilySearch, + new TavilyExtract, + new TavilyCrawl, + new TavilyMap, +]; +``` + +## Tools + +### TavilySearch + +Search the web for real-time information. + +| Parameter | Type | Required | Description | +|---------------|---------|----------|--------------------------------------------------------------------| +| `query` | string | yes | The search query to look up on the web. | +| `max_results` | integer | no | Maximum number of search results to return (1-10). Defaults to 5. | +| `search_depth` | string | no | `"basic"` for fast results or `"advanced"` for comprehensive. Defaults to `"basic"`. | +| `include_answer` | boolean | no | Whether to include a concise AI-generated answer. Defaults to false. | + +### TavilyExtract + +Extract clean, structured content from URLs. + +| Parameter | Type | Required | Description | +|---------------|---------|----------|--------------------------------------------------------------------| +| `urls` | string | yes | A single URL or comma-separated URLs to extract content from. | +| `query` | string | no | Optional query to rerank extracted chunks by relevance. | +| `extract_depth` | string | no | `"basic"` or `"advanced"`. Defaults to `"basic"`. | +| `format` | string | no | `"markdown"` or `"text"`. Defaults to `"markdown"`. | +| `include_images` | boolean | no | Whether to include images extracted from URLs. Defaults to false. | + +### TavilyCrawl + +Intelligently crawl a website and extract content. + +| Parameter | Type | Required | Description | +|---------------|---------|----------|--------------------------------------------------------------------| +| `url` | string | yes | The root URL to begin the crawl from. | +| `instructions` | string | no | Optional natural language instructions for the crawler. | +| `max_depth` | integer | no | Maximum crawl depth (1-5). Defaults to 1. | +| `max_breadth` | integer | no | Maximum links to follow per page (1-500). Defaults to 20. | +| `limit` | integer | no | Total number of links to process. Defaults to 50. | +| `extract_depth` | string | no | `"basic"` or `"advanced"`. Defaults to `"basic"`. | +| `allow_external` | boolean | no | Whether to allow crawling external domains. Defaults to false. | + +### TavilyMap + +Discover and map a website's structure. + +| Parameter | Type | Required | Description | +|---------------|---------|----------|--------------------------------------------------------------------| +| `url` | string | yes | The root URL to begin the mapping from. | +| `instructions` | string | no | Optional natural language instructions for the crawler. | +| `max_depth` | integer | no | Maximum map depth (1-5). Defaults to 1. | +| `max_breadth` | integer | no | Maximum links to follow per page (1-500). Defaults to 20. | +| `limit` | integer | no | Total number of links to process. Defaults to 50. | +| `allow_external` | boolean | no | Whether to allow crawling external domains. Defaults to false. | + +## Provider setup + +All tools read their API credentials from Laravel's `services` config and their optional defaults from the `ai` config. + +### 1. Add the Tavily service to `config/services.php` + +```php +// config/services.php + +return [ + + // ... existing services ... + + 'tavily' => [ + 'key' => env('TAVILY_API_KEY'), + ], + +]; +``` + +### 2. Add toolkit defaults to `config/ai.php` + +```php +// config/ai.php + +return [ + + // ... existing laravel/ai config ... + + 'toolkit' => [ + 'tavily' => [ + 'search' => [ + 'max_results' => (int) env('TAVILY_SEARCH_MAX_RESULTS', 5), + 'search_depth' => env('TAVILY_SEARCH_DEPTH', 'basic'), + 'include_answer' => (bool) env('TAVILY_SEARCH_INCLUDE_ANSWER', false), + ], + 'extract' => [ + 'extract_depth' => env('TAVILY_EXTRACT_DEPTH', 'basic'), + 'format' => env('TAVILY_EXTRACT_FORMAT', 'markdown'), + ], + 'crawl' => [ + 'max_depth' => (int) env('TAVILY_CRAWL_MAX_DEPTH', 1), + 'max_breadth' => (int) env('TAVILY_CRAWL_MAX_BREADTH', 20), + 'limit' => (int) env('TAVILY_CRAWL_LIMIT', 50), + 'extract_depth' => env('TAVILY_CRAWL_EXTRACT_DEPTH', 'basic'), + ], + 'map' => [ + 'max_depth' => (int) env('TAVILY_MAP_MAX_DEPTH', 1), + 'max_breadth' => (int) env('TAVILY_MAP_MAX_BREADTH', 20), + 'limit' => (int) env('TAVILY_MAP_LIMIT', 50), + ], + ], + ], + +]; +``` + +### 3. Add environment variables to `.env` + +```dotenv +TAVILY_API_KEY=tvly-your-key-here + +# Search defaults +TAVILY_SEARCH_MAX_RESULTS=5 +TAVILY_SEARCH_DEPTH=basic +TAVILY_SEARCH_INCLUDE_ANSWER=false + +# Extract defaults +TAVILY_EXTRACT_DEPTH=basic +TAVILY_EXTRACT_FORMAT=markdown + +# Crawl defaults +TAVILY_CRAWL_MAX_DEPTH=1 +TAVILY_CRAWL_MAX_BREADTH=20 +TAVILY_CRAWL_LIMIT=50 +TAVILY_CRAWL_EXTRACT_DEPTH=basic + +# Map defaults +TAVILY_MAP_MAX_DEPTH=1 +TAVILY_MAP_MAX_BREADTH=20 +TAVILY_MAP_LIMIT=50 +``` + +| Config key | Env var | Default | Description | +|---|---|---|---| +| `services.tavily.key` | `TAVILY_API_KEY` | - | **Required.** Your Tavily API key. | +| `ai.toolkit.tavily.search.max_results` | `TAVILY_SEARCH_MAX_RESULTS` | `5` | Default search results (1-10). | +| `ai.toolkit.tavily.search.search_depth` | `TAVILY_SEARCH_DEPTH` | `"basic"` | `"basic"` or `"advanced"`. | +| `ai.toolkit.tavily.search.include_answer` | `TAVILY_SEARCH_INCLUDE_ANSWER` | `false` | Default for AI-generated answer. | +| `ai.toolkit.tavily.extract.extract_depth` | `TAVILY_EXTRACT_DEPTH` | `"basic"` | `"basic"` or `"advanced"`. | +| `ai.toolkit.tavily.extract.format` | `TAVILY_EXTRACT_FORMAT` | `"markdown"` | `"markdown"` or `"text"`. | +| `ai.toolkit.tavily.crawl.max_depth` | `TAVILY_CRAWL_MAX_DEPTH` | `1` | Max crawl depth (1-5). | +| `ai.toolkit.tavily.crawl.max_breadth` | `TAVILY_CRAWL_MAX_BREADTH` | `20` | Max links per page (1-500). | +| `ai.toolkit.tavily.crawl.limit` | `TAVILY_CRAWL_LIMIT` | `50` | Total links to process. | +| `ai.toolkit.tavily.crawl.extract_depth` | `TAVILY_CRAWL_EXTRACT_DEPTH` | `"basic"` | `"basic"` or `"advanced"`. | +| `ai.toolkit.tavily.map.max_depth` | `TAVILY_MAP_MAX_DEPTH` | `1` | Max map depth (1-5). | +| `ai.toolkit.tavily.map.max_breadth` | `TAVILY_MAP_MAX_BREADTH` | `20` | Max links per page (1-500). | +| `ai.toolkit.tavily.map.limit` | `TAVILY_MAP_LIMIT` | `50` | Total links to process. | + +## Safety + +- All tools validate required inputs before calling the API. +- Numeric parameters are clamped to their valid ranges. +- API errors are caught and returned as friendly string messages. +- Requires a valid Tavily API key. + +## Tavily API + +These tools use the Tavily API. Tavily offers a generous free tier with 1,000 API credits per month. + +Full API reference: +- [Search Endpoint](https://docs.tavily.com/documentation/api-reference/endpoint/search) +- [Extract Endpoint](https://docs.tavily.com/documentation/api-reference/endpoint/extract) +- [Crawl Endpoint](https://docs.tavily.com/documentation/api-reference/endpoint/crawl) +- [Map Endpoint](https://docs.tavily.com/documentation/api-reference/endpoint/map) diff --git a/src/Tavily/README.md b/src/Tavily/README.md new file mode 100644 index 0000000..e5b6741 --- /dev/null +++ b/src/Tavily/README.md @@ -0,0 +1,211 @@ +# shipfastlabs/toolkit-tavily + +[![Latest Version](https://img.shields.io/packagist/v/shipfastlabs/toolkit-tavily.svg)](https://packagist.org/packages/shipfastlabs/toolkit-tavily) +[![Total Downloads](https://img.shields.io/packagist/dt/shipfastlabs/toolkit-tavily.svg)](https://packagist.org/packages/shipfastlabs/toolkit-tavily) + +> Tavily tools for the Laravel AI SDK - Search, Extract, Crawl, and Map + +Part of the [shipfastlabs/toolkit](https://github.com/shipfastlabs/toolkit) catalog of reusable AI tools for the Laravel AI SDK. + + + + +## Installation + +```bash +composer require shipfastlabs/toolkit-tavily +``` + +## Usage + +Register every Tavily tool at once with the `Tavily` helper: + +```php +use Shipfastlabs\Toolkit\Tavily\Tavily; + +$tools = Tavily::all(); // Collection +``` + +Or add individual tools to an agent's `tools()`: + +```php +use Shipfastlabs\Toolkit\Tavily\TavilySearch; +use Shipfastlabs\Toolkit\Tavily\TavilyExtract; +use Shipfastlabs\Toolkit\Tavily\TavilyCrawl; +use Shipfastlabs\Toolkit\Tavily\TavilyMap; + +$tools = [ + new TavilySearch, + new TavilyExtract, + new TavilyCrawl, + new TavilyMap, +]; +``` + +## Tools + +### TavilySearch + +Search the web for real-time information. + +| Parameter | Type | Required | Description | +|---------------|---------|----------|--------------------------------------------------------------------| +| `query` | string | yes | The search query to look up on the web. | +| `max_results` | integer | no | Maximum number of search results to return (1-10). Defaults to 5. | +| `search_depth` | string | no | `"basic"` for fast results or `"advanced"` for comprehensive. Defaults to `"basic"`. | +| `include_answer` | boolean | no | Whether to include a concise AI-generated answer. Defaults to false. | + +### TavilyExtract + +Extract clean, structured content from URLs. + +| Parameter | Type | Required | Description | +|---------------|---------|----------|--------------------------------------------------------------------| +| `urls` | string | yes | A single URL or comma-separated URLs to extract content from. | +| `query` | string | no | Optional query to rerank extracted chunks by relevance. | +| `extract_depth` | string | no | `"basic"` or `"advanced"`. Defaults to `"basic"`. | +| `format` | string | no | `"markdown"` or `"text"`. Defaults to `"markdown"`. | +| `include_images` | boolean | no | Whether to include images extracted from URLs. Defaults to false. | + +### TavilyCrawl + +Intelligently crawl a website and extract content. + +| Parameter | Type | Required | Description | +|---------------|---------|----------|--------------------------------------------------------------------| +| `url` | string | yes | The root URL to begin the crawl from. | +| `instructions` | string | no | Optional natural language instructions for the crawler. | +| `max_depth` | integer | no | Maximum crawl depth (1-5). Defaults to 1. | +| `max_breadth` | integer | no | Maximum links to follow per page (1-500). Defaults to 20. | +| `limit` | integer | no | Total number of links to process. Defaults to 50. | +| `extract_depth` | string | no | `"basic"` or `"advanced"`. Defaults to `"basic"`. | +| `allow_external` | boolean | no | Whether to allow crawling external domains. Defaults to false. | + +### TavilyMap + +Discover and map a website's structure. + +| Parameter | Type | Required | Description | +|---------------|---------|----------|--------------------------------------------------------------------| +| `url` | string | yes | The root URL to begin the mapping from. | +| `instructions` | string | no | Optional natural language instructions for the crawler. | +| `max_depth` | integer | no | Maximum map depth (1-5). Defaults to 1. | +| `max_breadth` | integer | no | Maximum links to follow per page (1-500). Defaults to 20. | +| `limit` | integer | no | Total number of links to process. Defaults to 50. | +| `allow_external` | boolean | no | Whether to allow crawling external domains. Defaults to false. | + +## Provider setup + +All tools read their API credentials from Laravel's `services` config and their optional defaults from the `ai` config. + +### 1. Add the Tavily service to `config/services.php` + +```php +// config/services.php + +return [ + + // ... existing services ... + + 'tavily' => [ + 'key' => env('TAVILY_API_KEY'), + ], + +]; +``` + +### 2. Add toolkit defaults to `config/ai.php` + +```php +// config/ai.php + +return [ + + // ... existing laravel/ai config ... + + 'toolkit' => [ + 'tavily' => [ + 'search' => [ + 'max_results' => (int) env('TAVILY_SEARCH_MAX_RESULTS', 5), + 'search_depth' => env('TAVILY_SEARCH_DEPTH', 'basic'), + 'include_answer' => (bool) env('TAVILY_SEARCH_INCLUDE_ANSWER', false), + ], + 'extract' => [ + 'extract_depth' => env('TAVILY_EXTRACT_DEPTH', 'basic'), + 'format' => env('TAVILY_EXTRACT_FORMAT', 'markdown'), + ], + 'crawl' => [ + 'max_depth' => (int) env('TAVILY_CRAWL_MAX_DEPTH', 1), + 'max_breadth' => (int) env('TAVILY_CRAWL_MAX_BREADTH', 20), + 'limit' => (int) env('TAVILY_CRAWL_LIMIT', 50), + 'extract_depth' => env('TAVILY_CRAWL_EXTRACT_DEPTH', 'basic'), + ], + 'map' => [ + 'max_depth' => (int) env('TAVILY_MAP_MAX_DEPTH', 1), + 'max_breadth' => (int) env('TAVILY_MAP_MAX_BREADTH', 20), + 'limit' => (int) env('TAVILY_MAP_LIMIT', 50), + ], + ], + ], + +]; +``` + +### 3. Add environment variables to `.env` + +```dotenv +TAVILY_API_KEY=tvly-your-key-here + +# Search defaults +TAVILY_SEARCH_MAX_RESULTS=5 +TAVILY_SEARCH_DEPTH=basic +TAVILY_SEARCH_INCLUDE_ANSWER=false + +# Extract defaults +TAVILY_EXTRACT_DEPTH=basic +TAVILY_EXTRACT_FORMAT=markdown + +# Crawl defaults +TAVILY_CRAWL_MAX_DEPTH=1 +TAVILY_CRAWL_MAX_BREADTH=20 +TAVILY_CRAWL_LIMIT=50 +TAVILY_CRAWL_EXTRACT_DEPTH=basic + +# Map defaults +TAVILY_MAP_MAX_DEPTH=1 +TAVILY_MAP_MAX_BREADTH=20 +TAVILY_MAP_LIMIT=50 +``` + +| Config key | Env var | Default | Description | +|---|---|---|---| +| `services.tavily.key` | `TAVILY_API_KEY` | - | **Required.** Your Tavily API key. | +| `ai.toolkit.tavily.search.max_results` | `TAVILY_SEARCH_MAX_RESULTS` | `5` | Default search results (1-10). | +| `ai.toolkit.tavily.search.search_depth` | `TAVILY_SEARCH_DEPTH` | `"basic"` | `"basic"` or `"advanced"`. | +| `ai.toolkit.tavily.search.include_answer` | `TAVILY_SEARCH_INCLUDE_ANSWER` | `false` | Default for AI-generated answer. | +| `ai.toolkit.tavily.extract.extract_depth` | `TAVILY_EXTRACT_DEPTH` | `"basic"` | `"basic"` or `"advanced"`. | +| `ai.toolkit.tavily.extract.format` | `TAVILY_EXTRACT_FORMAT` | `"markdown"` | `"markdown"` or `"text"`. | +| `ai.toolkit.tavily.crawl.max_depth` | `TAVILY_CRAWL_MAX_DEPTH` | `1` | Max crawl depth (1-5). | +| `ai.toolkit.tavily.crawl.max_breadth` | `TAVILY_CRAWL_MAX_BREADTH` | `20` | Max links per page (1-500). | +| `ai.toolkit.tavily.crawl.limit` | `TAVILY_CRAWL_LIMIT` | `50` | Total links to process. | +| `ai.toolkit.tavily.crawl.extract_depth` | `TAVILY_CRAWL_EXTRACT_DEPTH` | `"basic"` | `"basic"` or `"advanced"`. | +| `ai.toolkit.tavily.map.max_depth` | `TAVILY_MAP_MAX_DEPTH` | `1` | Max map depth (1-5). | +| `ai.toolkit.tavily.map.max_breadth` | `TAVILY_MAP_MAX_BREADTH` | `20` | Max links per page (1-500). | +| `ai.toolkit.tavily.map.limit` | `TAVILY_MAP_LIMIT` | `50` | Total links to process. | + +## Safety + +- All tools validate required inputs before calling the API. +- Numeric parameters are clamped to their valid ranges. +- API errors are caught and returned as friendly string messages. +- Requires a valid Tavily API key. + +## Tavily API + +These tools use the Tavily API. Tavily offers a generous free tier with 1,000 API credits per month. + +Full API reference: +- [Search Endpoint](https://docs.tavily.com/documentation/api-reference/endpoint/search) +- [Extract Endpoint](https://docs.tavily.com/documentation/api-reference/endpoint/extract) +- [Crawl Endpoint](https://docs.tavily.com/documentation/api-reference/endpoint/crawl) +- [Map Endpoint](https://docs.tavily.com/documentation/api-reference/endpoint/map) diff --git a/src/Tavily/composer.json b/src/Tavily/composer.json new file mode 100644 index 0000000..0b2b841 --- /dev/null +++ b/src/Tavily/composer.json @@ -0,0 +1,24 @@ +{ + "name": "shipfastlabs/toolkit-tavily", + "description": "Tavily tools for the Laravel AI SDK - Search, Extract, Crawl, and Map", + "keywords": ["laravel", "ai", "tool", "tavily", "search", "extract", "crawl", "map"], + "license": "MIT", + "require": { + "php": "^8.4.0", + "illuminate/contracts": "^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", + "laravel/ai": "^0.7" + }, + "autoload": { + "psr-4": { + "Shipfastlabs\\Toolkit\\Tavily\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Shipfastlabs\\Toolkit\\Tavily\\Tests\\": "tests/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/src/Tavily/src/Tavily.php b/src/Tavily/src/Tavily.php new file mode 100644 index 0000000..3d3685e --- /dev/null +++ b/src/Tavily/src/Tavily.php @@ -0,0 +1,24 @@ + + */ + public static function all(): Collection + { + return new Collection([ + new TavilySearch, + new TavilyExtract, + new TavilyCrawl, + new TavilyMap, + ]); + } +} diff --git a/src/Tavily/src/TavilyCrawl.php b/src/Tavily/src/TavilyCrawl.php new file mode 100644 index 0000000..6e26dc9 --- /dev/null +++ b/src/Tavily/src/TavilyCrawl.php @@ -0,0 +1,165 @@ + $schema + ->string() + ->description('The base URL to start crawling from') + ->required(), + 'instructions' => $schema + ->string() + ->description("Optional instructions to guide the crawler (e.g., 'only crawl blog posts', 'focus on product pages')") + ->nullable() + ->required(), + 'max_depth' => $schema + ->integer() + ->description('Maximum depth to crawl (number of link hops from the base URL, 1-5, default: 1)') + ->nullable() + ->required(), + 'max_breadth' => $schema + ->integer() + ->description('Maximum number of links to follow per page (1-500, default: 20)') + ->nullable() + ->required(), + 'limit' => $schema + ->integer() + ->description('Total number of links to process before stopping (default: 50)') + ->nullable() + ->required(), + 'extract_depth' => $schema + ->string() + ->description("Extraction depth for page content - 'basic' or 'advanced' (default: 'basic')") + ->nullable() + ->required(), + 'allow_external' => $schema + ->boolean() + ->description('Whether to allow crawling external domains (default: false)') + ->nullable() + ->required(), + ]; + } + + public function handle(Request $request): string + { + $url = trim((string) $request->string('url')); + + if ($url === '') { + return 'The URL is empty. Provide a root URL to crawl.'; + } + + $apiKey = config('services.tavily.key'); + + if (! is_string($apiKey) || $apiKey === '') { + return 'The Tavily tool is not configured. Set services.tavily.key in your config/services.php file.'; + } + + $payload = ['url' => $url]; + + if ($request->has('instructions') && $request['instructions'] !== null) { + $payload['instructions'] = (string) $request->string('instructions'); + } + + $maxDepth = $request->has('max_depth') && $request['max_depth'] !== null + ? max(1, min(5, $request->integer('max_depth'))) + : $this->defaultMaxDepth(); + $payload['max_depth'] = $maxDepth; + + $maxBreadth = $request->has('max_breadth') && $request['max_breadth'] !== null + ? max(1, min(500, $request->integer('max_breadth'))) + : $this->defaultMaxBreadth(); + $payload['max_breadth'] = $maxBreadth; + + $limit = $request->has('limit') && $request['limit'] !== null + ? max(1, $request->integer('limit')) + : $this->defaultLimit(); + $payload['limit'] = $limit; + + $extractDepth = $request->has('extract_depth') && $request['extract_depth'] !== null + ? $this->validateExtractDepth((string) $request->string('extract_depth')) + : $this->defaultExtractDepth(); + $payload['extract_depth'] = $extractDepth; + + if ($request->has('allow_external') && $request['allow_external'] !== null) { + $payload['allow_external'] = $request->boolean('allow_external'); + } + + try { + $response = Http::timeout(150) + ->withToken($apiKey) + ->post('https://api.tavily.com/crawl', $payload); + } catch (Throwable $throwable) { + return sprintf('The Tavily crawl request failed: %s', $throwable->getMessage()); + } + + if ($response->failed()) { + return sprintf( + 'The Tavily crawl request failed with status %d: %s', + $response->status(), + $response->body() + ); + } + + $data = $response->json(); + + if (! is_array($data)) { + return 'The Tavily crawl response was invalid.'; + } + + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } + + private function defaultMaxDepth(): int + { + $depth = config('ai.toolkit.tavily.crawl.max_depth', 1); + + return is_numeric($depth) ? max(1, min(5, (int) $depth)) : 1; + } + + private function defaultMaxBreadth(): int + { + $breadth = config('ai.toolkit.tavily.crawl.max_breadth', 20); + + return is_numeric($breadth) ? max(1, min(500, (int) $breadth)) : 20; + } + + private function defaultLimit(): int + { + $limit = config('ai.toolkit.tavily.crawl.limit', 50); + + return is_numeric($limit) ? max(1, (int) $limit) : 50; + } + + private function defaultExtractDepth(): string + { + $depth = config('ai.toolkit.tavily.crawl.extract_depth', 'basic'); + + return $this->validateExtractDepth(is_string($depth) ? $depth : 'basic'); + } + + private function validateExtractDepth(string $depth): string + { + return in_array($depth, ['basic', 'advanced'], true) ? $depth : 'basic'; + } +} diff --git a/src/Tavily/src/TavilyExtract.php b/src/Tavily/src/TavilyExtract.php new file mode 100644 index 0000000..cfee2de --- /dev/null +++ b/src/Tavily/src/TavilyExtract.php @@ -0,0 +1,148 @@ + $schema + ->string() + ->description('A single URL or comma-separated list of URLs to extract content from') + ->required(), + 'query' => $schema + ->string() + ->description('User intent query for reranking extracted content chunks') + ->nullable() + ->required(), + 'extract_depth' => $schema + ->string() + ->description("Extraction depth - 'basic' for main content, 'advanced' for comprehensive extraction (default: 'basic')") + ->nullable() + ->required(), + 'format' => $schema + ->string() + ->description("Output format for the extracted content - 'markdown' or 'text' (default: 'markdown')") + ->nullable() + ->required(), + 'include_images' => $schema + ->boolean() + ->description('Whether to include a list of images extracted from the URLs (default: false)') + ->nullable() + ->required(), + ]; + } + + public function handle(Request $request): string + { + $urlsInput = trim((string) $request->string('urls')); + + if ($urlsInput === '') { + return 'The URLs are empty. Provide at least one URL to extract.'; + } + + $urls = array_filter(array_map(trim(...), explode(',', $urlsInput))); + + if ($urls === []) { + return 'No valid URLs were provided.'; + } + + if (count($urls) > 20) { + return 'A maximum of 20 URLs is allowed per request.'; + } + + $apiKey = config('services.tavily.key'); + + if (! is_string($apiKey) || $apiKey === '') { + return 'The Tavily tool is not configured. Set services.tavily.key in your config/services.php file.'; + } + + $payload = [ + 'urls' => count($urls) === 1 ? $urls[0] : $urls, + ]; + + if ($request->has('query') && $request['query'] !== null) { + $payload['query'] = (string) $request->string('query'); + } + + $extractDepth = $request->has('extract_depth') && $request['extract_depth'] !== null + ? $this->validateExtractDepth((string) $request->string('extract_depth')) + : $this->defaultExtractDepth(); + $payload['extract_depth'] = $extractDepth; + + $format = $request->has('format') && $request['format'] !== null + ? $this->validateFormat((string) $request->string('format')) + : $this->defaultFormat(); + $payload['format'] = $format; + + if ($request->has('include_images') && $request['include_images'] !== null) { + $payload['include_images'] = $request->boolean('include_images'); + } + + try { + $response = Http::timeout(30) + ->withToken($apiKey) + ->post('https://api.tavily.com/extract', $payload); + } catch (Throwable $throwable) { + return sprintf('The Tavily extract request failed: %s', $throwable->getMessage()); + } + + if ($response->failed()) { + return sprintf( + 'The Tavily extract request failed with status %d: %s', + $response->status(), + $response->body() + ); + } + + $data = $response->json(); + + if (! is_array($data)) { + return 'The Tavily extract response was invalid.'; + } + + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } + + private function defaultExtractDepth(): string + { + $depth = config('ai.toolkit.tavily.extract.extract_depth', 'basic'); + + return $this->validateExtractDepth(is_string($depth) ? $depth : 'basic'); + } + + private function validateExtractDepth(string $depth): string + { + return in_array($depth, ['basic', 'advanced'], true) ? $depth : 'basic'; + } + + private function defaultFormat(): string + { + $format = config('ai.toolkit.tavily.extract.format', 'markdown'); + + return $this->validateFormat(is_string($format) ? $format : 'markdown'); + } + + private function validateFormat(string $format): string + { + return in_array($format, ['markdown', 'text'], true) ? $format : 'markdown'; + } +} diff --git a/src/Tavily/src/TavilyMap.php b/src/Tavily/src/TavilyMap.php new file mode 100644 index 0000000..2160b31 --- /dev/null +++ b/src/Tavily/src/TavilyMap.php @@ -0,0 +1,143 @@ + $schema + ->string() + ->description('The base URL to start mapping from') + ->required(), + 'instructions' => $schema + ->string() + ->description("Optional instructions to guide the mapping (e.g., 'focus on documentation pages', 'skip API references')") + ->nullable() + ->required(), + 'max_depth' => $schema + ->integer() + ->description('Maximum depth to map (number of link hops from the base URL, 1-5, default: 1)') + ->nullable() + ->required(), + 'max_breadth' => $schema + ->integer() + ->description('Maximum number of links to follow per page (1-500, default: 20)') + ->nullable() + ->required(), + 'limit' => $schema + ->integer() + ->description('Total number of links to process before stopping (default: 50)') + ->nullable() + ->required(), + 'allow_external' => $schema + ->boolean() + ->description('Whether to allow mapping external domains (default: false)') + ->nullable() + ->required(), + ]; + } + + public function handle(Request $request): string + { + $url = trim((string) $request->string('url')); + + if ($url === '') { + return 'The URL is empty. Provide a root URL to map.'; + } + + $apiKey = config('services.tavily.key'); + + if (! is_string($apiKey) || $apiKey === '') { + return 'The Tavily tool is not configured. Set services.tavily.key in your config/services.php file.'; + } + + $payload = ['url' => $url]; + + if ($request->has('instructions') && $request['instructions'] !== null) { + $payload['instructions'] = (string) $request->string('instructions'); + } + + $maxDepth = $request->has('max_depth') && $request['max_depth'] !== null + ? max(1, min(5, $request->integer('max_depth'))) + : $this->defaultMaxDepth(); + $payload['max_depth'] = $maxDepth; + + $maxBreadth = $request->has('max_breadth') && $request['max_breadth'] !== null + ? max(1, min(500, $request->integer('max_breadth'))) + : $this->defaultMaxBreadth(); + $payload['max_breadth'] = $maxBreadth; + + $limit = $request->has('limit') && $request['limit'] !== null + ? max(1, $request->integer('limit')) + : $this->defaultLimit(); + $payload['limit'] = $limit; + + if ($request->has('allow_external') && $request['allow_external'] !== null) { + $payload['allow_external'] = $request->boolean('allow_external'); + } + + try { + $response = Http::timeout(150) + ->withToken($apiKey) + ->post('https://api.tavily.com/map', $payload); + } catch (Throwable $throwable) { + return sprintf('The Tavily map request failed: %s', $throwable->getMessage()); + } + + if ($response->failed()) { + return sprintf( + 'The Tavily map request failed with status %d: %s', + $response->status(), + $response->body() + ); + } + + $data = $response->json(); + + if (! is_array($data)) { + return 'The Tavily map response was invalid.'; + } + + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } + + private function defaultMaxDepth(): int + { + $depth = config('ai.toolkit.tavily.map.max_depth', 1); + + return is_numeric($depth) ? max(1, min(5, (int) $depth)) : 1; + } + + private function defaultMaxBreadth(): int + { + $breadth = config('ai.toolkit.tavily.map.max_breadth', 20); + + return is_numeric($breadth) ? max(1, min(500, (int) $breadth)) : 20; + } + + private function defaultLimit(): int + { + $limit = config('ai.toolkit.tavily.map.limit', 50); + + return is_numeric($limit) ? max(1, (int) $limit) : 50; + } +} diff --git a/src/Tavily/src/TavilySearch.php b/src/Tavily/src/TavilySearch.php new file mode 100644 index 0000000..17a9d1e --- /dev/null +++ b/src/Tavily/src/TavilySearch.php @@ -0,0 +1,128 @@ + $schema + ->string() + ->description('The search query to look up on the web') + ->required(), + 'max_results' => $schema + ->integer() + ->description('Maximum number of search results to return (1-10, default: 5)') + ->nullable() + ->required(), + 'search_depth' => $schema + ->string() + ->description("The depth of the search - 'basic' for quick results, 'advanced' for comprehensive search (default: 'basic')") + ->nullable() + ->required(), + 'include_answer' => $schema + ->boolean() + ->description('Whether to include an AI-generated answer summarizing the results (default: false)') + ->nullable() + ->required(), + ]; + } + + public function handle(Request $request): string + { + $query = trim((string) $request->string('query')); + + if ($query === '') { + return 'The search query is empty. Provide a query to search for.'; + } + + $apiKey = config('services.tavily.key'); + + if (empty($apiKey)) { + return 'The Tavily tool is not configured. Set services.tavily.key in your config/services.php file.'; + } + + $maxResults = $request->has('max_results') && $request['max_results'] !== null + ? $request->integer('max_results') + : $this->defaultMaxResults(); + + $searchDepth = $request->has('search_depth') && $request['search_depth'] !== null + ? $this->validateSearchDepth((string) $request->string('search_depth')) + : $this->defaultSearchDepth(); + + $includeAnswer = $request->has('include_answer') && $request['include_answer'] !== null + ? $request->boolean('include_answer') + : $this->defaultIncludeAnswer(); + + try { + $response = Http::timeout(30) + ->post('https://api.tavily.com/search', [ + 'api_key' => $apiKey, + 'query' => $query, + 'max_results' => max(1, min(10, $maxResults)), + 'search_depth' => $searchDepth, + 'include_answer' => $includeAnswer, + ]); + } catch (Throwable $throwable) { + return sprintf('The Tavily search request failed: %s', $throwable->getMessage()); + } + + if ($response->failed()) { + return sprintf( + 'The Tavily search request failed with status %d: %s', + $response->status(), + $response->body() + ); + } + + $data = $response->json(); + + if (! is_array($data)) { + return 'The Tavily search response was invalid.'; + } + + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } + + private function defaultMaxResults(): int + { + $max = config('ai.toolkit.tavily.search.max_results', 5); + + return is_numeric($max) ? (int) $max : 5; + } + + private function defaultSearchDepth(): string + { + $depth = config('ai.toolkit.tavily.search.search_depth', 'basic'); + + return $this->validateSearchDepth(is_string($depth) ? $depth : 'basic'); + } + + private function validateSearchDepth(string $depth): string + { + return in_array($depth, ['basic', 'advanced'], true) ? $depth : 'basic'; + } + + private function defaultIncludeAnswer(): bool + { + return (bool) config('ai.toolkit.tavily.search.include_answer', false); + } +} diff --git a/src/Tavily/tests/TavilyCrawlTest.php b/src/Tavily/tests/TavilyCrawlTest.php new file mode 100644 index 0000000..21c6c30 --- /dev/null +++ b/src/Tavily/tests/TavilyCrawlTest.php @@ -0,0 +1,132 @@ +description())->toContain('Crawl a website'); +}); + +it('is marked as strict', function (): void { + expect(Strict::isAppliedTo(new TavilyCrawl))->toBeTrue(); +}); + +it('exposes its schema', function (): void { + $schema = (new TavilyCrawl)->schema(new JsonSchemaTypeFactory); + + expect($schema)->toHaveKey('url') + ->and($schema)->toHaveKey('instructions') + ->and($schema)->toHaveKey('max_depth') + ->and($schema)->toHaveKey('max_breadth') + ->and($schema)->toHaveKey('limit') + ->and($schema)->toHaveKey('extract_depth') + ->and($schema)->toHaveKey('allow_external'); +}); + +it('returns an error when url is empty', function (): void { + $result = (new TavilyCrawl)->handle(new Request(['url' => ' '])); + + expect($result)->toContain('empty'); +}); + +it('returns an error when no api key is configured', function (): void { + config()->set('services.tavily.key'); + + $result = (new TavilyCrawl)->handle(new Request(['url' => 'https://example.com'])); + + expect($result)->toContain('not configured'); +}); + +it('returns crawl results on success', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake([ + 'https://api.tavily.com/crawl' => Http::response([ + 'base_url' => 'https://example.com', + 'results' => [ + ['url' => 'https://example.com/page1', 'raw_content' => 'Page 1'], + ], + ]), + ]); + + $result = (new TavilyCrawl)->handle(new Request(['url' => 'https://example.com'])); + + expect($result)->toContain('Page 1'); +}); + +it('uses bearer token for authentication', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) { + expect($request->header('Authorization'))->toContain('Bearer test-key'); + + return Http::response([ + 'base_url' => 'https://example.com', + 'results' => [], + ]); + }); + + (new TavilyCrawl)->handle(new Request(['url' => 'https://example.com'])); +}); + +it('respects custom crawl parameters', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) { + expect($request->data()) + ->toHaveKey('max_depth', 2) + ->toHaveKey('max_breadth', 10) + ->toHaveKey('limit', 25) + ->toHaveKey('extract_depth', 'advanced') + ->toHaveKey('allow_external', false); + + return Http::response([ + 'base_url' => 'https://example.com', + 'results' => [], + ]); + }); + + (new TavilyCrawl)->handle(new Request([ + 'url' => 'https://example.com', + 'max_depth' => 2, + 'max_breadth' => 10, + 'limit' => 25, + 'extract_depth' => 'advanced', + 'allow_external' => false, + ])); +}); + +it('clamps max_depth between 1 and 5', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) { + expect($request->data())->toHaveKey('max_depth', 1); + + return Http::response([ + 'base_url' => 'https://example.com', + 'results' => [], + ]); + }); + + (new TavilyCrawl)->handle(new Request(['url' => 'https://example.com', 'max_depth' => 0])); +}); + +it('clamps max_breadth between 1 and 500', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) { + expect($request->data())->toHaveKey('max_breadth', 500); + + return Http::response([ + 'base_url' => 'https://example.com', + 'results' => [], + ]); + }); + + (new TavilyCrawl)->handle(new Request(['url' => 'https://example.com', 'max_breadth' => 600])); +}); diff --git a/src/Tavily/tests/TavilyExtractTest.php b/src/Tavily/tests/TavilyExtractTest.php new file mode 100644 index 0000000..53e2269 --- /dev/null +++ b/src/Tavily/tests/TavilyExtractTest.php @@ -0,0 +1,156 @@ +description())->toContain('Extract clean, structured content'); +}); + +it('is marked as strict', function (): void { + expect(Strict::isAppliedTo(new TavilyExtract))->toBeTrue(); +}); + +it('exposes its schema', function (): void { + $schema = (new TavilyExtract)->schema(new JsonSchemaTypeFactory); + + expect($schema)->toHaveKey('urls') + ->and($schema)->toHaveKey('query') + ->and($schema)->toHaveKey('extract_depth') + ->and($schema)->toHaveKey('format') + ->and($schema)->toHaveKey('include_images'); +}); + +it('returns an error when urls are empty', function (): void { + $result = (new TavilyExtract)->handle(new Request(['urls' => ' '])); + + expect($result)->toContain('empty'); +}); + +it('returns an error when no api key is configured', function (): void { + config()->set('services.tavily.key'); + + $result = (new TavilyExtract)->handle(new Request(['urls' => 'https://example.com'])); + + expect($result)->toContain('not configured'); +}); + +it('returns extracted content on success', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake([ + 'https://api.tavily.com/extract' => Http::response([ + 'results' => [ + [ + 'url' => 'https://example.com', + 'raw_content' => 'Example content', + ], + ], + ]), + ]); + + $result = (new TavilyExtract)->handle(new Request(['urls' => 'https://example.com'])); + + expect($result)->toContain('Example content'); +}); + +it('rejects more than 20 urls', function (): void { + $urls = implode(',', array_fill(0, 21, 'https://example.com')); + + $result = (new TavilyExtract)->handle(new Request(['urls' => $urls])); + + expect($result)->toContain('maximum of 20'); +}); + +it('uses bearer token for authentication', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) { + expect($request->header('Authorization'))->toContain('Bearer test-key'); + + return Http::response([ + 'results' => [ + [ + 'url' => 'https://example.com', + 'raw_content' => 'Content', + ], + ], + ]); + }); + + (new TavilyExtract)->handle(new Request(['urls' => 'https://example.com'])); +}); + +it('respects custom extract_depth and format', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) { + expect($request->data()) + ->toHaveKey('extract_depth', 'advanced') + ->toHaveKey('format', 'text'); + + return Http::response([ + 'results' => [ + [ + 'url' => 'https://example.com', + 'raw_content' => 'Content', + ], + ], + ]); + }); + + (new TavilyExtract)->handle(new Request([ + 'urls' => 'https://example.com', + 'extract_depth' => 'advanced', + 'format' => 'text', + ])); +}); + +it('falls back to defaults for invalid extract_depth and format', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) { + expect($request->data()) + ->toHaveKey('extract_depth', 'basic') + ->toHaveKey('format', 'markdown'); + + return Http::response([ + 'results' => [ + [ + 'url' => 'https://example.com', + 'raw_content' => 'Content', + ], + ], + ]); + }); + + (new TavilyExtract)->handle(new Request([ + 'urls' => 'https://example.com', + 'extract_depth' => 'invalid', + 'format' => 'html', + ])); +}); + +it('handles multiple comma-separated urls', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) { + $urls = $request->data()['urls']; + + expect($urls)->toBeArray()->toHaveCount(2); + + return Http::response([ + 'results' => [ + ['url' => 'https://a.com', 'raw_content' => 'A'], + ['url' => 'https://b.com', 'raw_content' => 'B'], + ], + ]); + }); + + (new TavilyExtract)->handle(new Request(['urls' => 'https://a.com, https://b.com'])); +}); diff --git a/src/Tavily/tests/TavilyMapTest.php b/src/Tavily/tests/TavilyMapTest.php new file mode 100644 index 0000000..098617e --- /dev/null +++ b/src/Tavily/tests/TavilyMapTest.php @@ -0,0 +1,101 @@ +description())->toContain('Map the structure of a website'); +}); + +it('is marked as strict', function (): void { + expect(Strict::isAppliedTo(new TavilyMap))->toBeTrue(); +}); + +it('exposes its schema', function (): void { + $schema = (new TavilyMap)->schema(new JsonSchemaTypeFactory); + + expect($schema)->toHaveKey('url') + ->and($schema)->toHaveKey('instructions') + ->and($schema)->toHaveKey('max_depth') + ->and($schema)->toHaveKey('max_breadth') + ->and($schema)->toHaveKey('limit') + ->and($schema)->toHaveKey('allow_external'); +}); + +it('returns an error when url is empty', function (): void { + $result = (new TavilyMap)->handle(new Request(['url' => ' '])); + + expect($result)->toContain('empty'); +}); + +it('returns an error when no api key is configured', function (): void { + config()->set('services.tavily.key'); + + $result = (new TavilyMap)->handle(new Request(['url' => 'https://example.com'])); + + expect($result)->toContain('not configured'); +}); + +it('returns map results on success', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake([ + 'https://api.tavily.com/map' => Http::response([ + 'base_url' => 'https://example.com', + 'results' => [ + 'https://example.com/page1', + 'https://example.com/page2', + ], + ]), + ]); + + $result = (new TavilyMap)->handle(new Request(['url' => 'https://example.com'])); + + expect($result)->toContain('https://example.com/page1') + ->and($result)->toContain('https://example.com/page2'); +}); + +it('uses bearer token for authentication', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) { + expect($request->header('Authorization'))->toContain('Bearer test-key'); + + return Http::response([ + 'base_url' => 'https://example.com', + 'results' => [], + ]); + }); + + (new TavilyMap)->handle(new Request(['url' => 'https://example.com'])); +}); + +it('respects custom map parameters', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) { + expect($request->data()) + ->toHaveKey('max_depth', 3) + ->toHaveKey('max_breadth', 50) + ->toHaveKey('limit', 100) + ->toHaveKey('allow_external', false); + + return Http::response([ + 'base_url' => 'https://example.com', + 'results' => [], + ]); + }); + + (new TavilyMap)->handle(new Request([ + 'url' => 'https://example.com', + 'max_depth' => 3, + 'max_breadth' => 50, + 'limit' => 100, + 'allow_external' => false, + ])); +}); diff --git a/src/Tavily/tests/TavilySearchTest.php b/src/Tavily/tests/TavilySearchTest.php new file mode 100644 index 0000000..3777f94 --- /dev/null +++ b/src/Tavily/tests/TavilySearchTest.php @@ -0,0 +1,208 @@ +description())->toContain('Search the web'); +}); + +it('is marked as strict', function (): void { + expect(Strict::isAppliedTo(new TavilySearch))->toBeTrue(); +}); + +it('exposes its schema', function (): void { + $schema = (new TavilySearch)->schema(new JsonSchemaTypeFactory); + + expect($schema)->toHaveKey('query') + ->and($schema)->toHaveKey('max_results') + ->and($schema)->toHaveKey('search_depth') + ->and($schema)->toHaveKey('include_answer'); +}); + +it('returns an error when the query is empty', function (): void { + $result = (new TavilySearch)->handle(new Request(['query' => ' '])); + + expect($result)->toContain('empty'); +}); + +it('returns an error when no api key is configured', function (): void { + config()->set('services.tavily.key'); + + $result = (new TavilySearch)->handle(new Request(['query' => 'Laravel news'])); + + expect($result)->toContain('not configured'); +}); + +it('returns search results on success', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake([ + 'https://api.tavily.com/search' => Http::response([ + 'query' => 'Laravel news', + 'results' => [ + [ + 'title' => 'Laravel 11 Released', + 'url' => 'https://laravel.com', + 'content' => 'Laravel 11 is here with new features.', + ], + ], + 'answer' => 'Laravel 11 was released with new features.', + ]), + ]); + + $result = (new TavilySearch)->handle(new Request(['query' => 'Laravel news'])); + + expect($result)->toContain('Laravel 11 Released') + ->and($result)->toContain('Laravel 11 is here'); +}); + +it('returns an error when the api responds with a failure', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake([ + 'https://api.tavily.com/search' => Http::response('Invalid API key', 401), + ]); + + $result = (new TavilySearch)->handle(new Request(['query' => 'Laravel news'])); + + expect($result)->toContain('failed with status 401'); +}); + +it('respects custom max_results', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) { + expect($request->data())->toHaveKey('max_results', 3); + + return Http::response([ + 'query' => 'test', + 'results' => [], + 'answer' => '', + ]); + }); + + (new TavilySearch)->handle(new Request(['query' => 'test', 'max_results' => 3])); +}); + +it('respects custom search_depth', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) { + expect($request->data())->toHaveKey('search_depth', 'advanced'); + + return Http::response([ + 'query' => 'test', + 'results' => [], + 'answer' => '', + ]); + }); + + (new TavilySearch)->handle(new Request(['query' => 'test', 'search_depth' => 'advanced'])); +}); + +it('respects custom include_answer', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) { + expect($request->data())->toHaveKey('include_answer', true); + + return Http::response([ + 'query' => 'test', + 'results' => [], + 'answer' => '', + ]); + }); + + (new TavilySearch)->handle(new Request(['query' => 'test', 'include_answer' => true])); +}); + +it('uses defaults when optional params are omitted', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) { + expect($request->data()) + ->toHaveKey('max_results', 5) + ->toHaveKey('search_depth', 'basic') + ->toHaveKey('include_answer', false); + + return Http::response([ + 'query' => 'test', + 'results' => [], + 'answer' => '', + ]); + }); + + (new TavilySearch)->handle(new Request(['query' => 'test'])); +}); + +it('uses defaults when optional params are explicitly null', function (): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) { + expect($request->data()) + ->toHaveKey('max_results', 5) + ->toHaveKey('search_depth', 'basic') + ->toHaveKey('include_answer', false); + + return Http::response([ + 'query' => 'test', + 'results' => [], + 'answer' => '', + ]); + }); + + (new TavilySearch)->handle(new Request([ + 'query' => 'test', + 'max_results' => null, + 'search_depth' => null, + 'include_answer' => null, + ])); +}); + +it('falls back to basic for invalid search_depth values', function (string $input): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) { + expect($request->data())->toHaveKey('search_depth', 'basic'); + + return Http::response([ + 'query' => 'test', + 'results' => [], + 'answer' => '', + ]); + }); + + (new TavilySearch)->handle(new Request(['query' => 'test', 'search_depth' => $input])); +})->with([ + 'empty' => [''], + 'random' => ['foobar'], + 'wrong case' => ['Advanced'], +]); + +it('clamps max_results between 1 and 10', function (int $input, int $expected): void { + config()->set('services.tavily.key', 'test-key'); + + Http::fake(function ($request) use ($expected) { + expect($request->data())->toHaveKey('max_results', $expected); + + return Http::response([ + 'query' => 'test', + 'results' => [], + 'answer' => '', + ]); + }); + + (new TavilySearch)->handle(new Request(['query' => 'test', 'max_results' => $input])); +})->with([ + 'too low' => [-5, 1], + 'zero' => [0, 1], + 'minimum' => [1, 1], + 'maximum' => [10, 10], + 'too high' => [15, 10], +]); diff --git a/src/Tavily/tests/TavilyTest.php b/src/Tavily/tests/TavilyTest.php new file mode 100644 index 0000000..f7dc16f --- /dev/null +++ b/src/Tavily/tests/TavilyTest.php @@ -0,0 +1,30 @@ +toBeInstanceOf(Collection::class) + ->and($tools)->toHaveCount(4) + ->and($tools->all())->toContainOnlyInstancesOf(Tool::class); +}); + +it('includes each Tavily tool exactly once', function (): void { + $classes = Tavily::all()->map(fn ($tool): string => $tool::class); + + expect($classes->all())->toEqualCanonicalizing([ + TavilySearch::class, + TavilyExtract::class, + TavilyCrawl::class, + TavilyMap::class, + ]); +}); diff --git a/testbench.yaml b/testbench.yaml new file mode 100644 index 0000000..76ba9c5 --- /dev/null +++ b/testbench.yaml @@ -0,0 +1,33 @@ +laravel: '@testbench' + +providers: + - Workbench\App\Providers\WorkbenchServiceProvider + +migrations: + - workbench/database/migrations + +seeders: + - Workbench\Database\Seeders\DatabaseSeeder + +workbench: + start: '/' + install: true + health: false + discovers: + web: true + api: true + commands: true + components: false + factories: true + views: true + build: + - asset-publish + - create-sqlite-db + - db-wipe + - migrate-fresh + assets: + - laravel-assets + sync: + - from: storage + to: workbench/storage + reverse: true diff --git a/workbench/app/Ai/Agents/ToolkitAgent.php b/workbench/app/Ai/Agents/ToolkitAgent.php new file mode 100644 index 0000000..bf9ce12 --- /dev/null +++ b/workbench/app/Ai/Agents/ToolkitAgent.php @@ -0,0 +1,42 @@ + + */ + public function tools(): iterable + { + return [ + new CalculatorTool, + new DatabaseQueryTool, + ...Tavily::all(), + ]; + } +} diff --git a/workbench/app/Console/Commands/AgentRun.php b/workbench/app/Console/Commands/AgentRun.php new file mode 100644 index 0000000..cbf2ac2 --- /dev/null +++ b/workbench/app/Console/Commands/AgentRun.php @@ -0,0 +1,56 @@ +prompt( + $this->argument('prompt'), + provider: $this->option('provider'), + model: $this->option('model') ?: null, + ); + } catch (Throwable $throwable) { + $this->components->error('Agent failed: '.$throwable->getMessage()); + + return self::FAILURE; + } + + $this->components->info('Agent response'); + $this->line($response->text); + + $this->newLine(); + $this->components->info('Tool calls'); + + foreach ($response->toolCalls as $call) { + $this->components->twoColumnDetail( + $call->name, + (string) json_encode($call->arguments, JSON_UNESCAPED_SLASHES), + ); + } + + foreach ($response->toolResults as $result) { + $this->components->twoColumnDetail( + 'result :: '.$result->name, + (string) str(is_string($result->result) ? $result->result : (string) json_encode($result->result))->limit(120), + ); + } + + return self::SUCCESS; + } +} diff --git a/workbench/app/Models/.gitkeep b/workbench/app/Models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/workbench/app/Models/User.php b/workbench/app/Models/User.php new file mode 100644 index 0000000..d2149b4 --- /dev/null +++ b/workbench/app/Models/User.php @@ -0,0 +1,30 @@ + + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } +} diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php new file mode 100644 index 0000000..8f3189f --- /dev/null +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -0,0 +1,35 @@ +safeLoad(); + } + + config([ + 'services.tavily.key' => env('TAVILY_API_KEY'), + 'ai.providers.openai.key' => env('OPENAI_API_KEY'), + ]); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->commands([ + AgentRun::class, + ]); + } + } +} diff --git a/workbench/bootstrap/.gitkeep b/workbench/bootstrap/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/workbench/database/factories/.gitkeep b/workbench/database/factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/workbench/database/factories/UserFactory.php b/workbench/database/factories/UserFactory.php new file mode 100644 index 0000000..5960c7f --- /dev/null +++ b/workbench/database/factories/UserFactory.php @@ -0,0 +1,42 @@ + + */ +class UserFactory extends Factory +{ + protected static ?string $password; + + /** + * @var class-string + */ + protected $model = User::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + public function unverified(): static + { + return $this->state(fn (array $attributes): array => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/workbench/database/migrations/.gitkeep b/workbench/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/workbench/database/seeders/DatabaseSeeder.php b/workbench/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..94dc892 --- /dev/null +++ b/workbench/database/seeders/DatabaseSeeder.php @@ -0,0 +1,22 @@ +create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } +} diff --git a/workbench/resources/views/.gitkeep b/workbench/resources/views/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/workbench/routes/console.php b/workbench/routes/console.php new file mode 100644 index 0000000..174d7fd --- /dev/null +++ b/workbench/routes/console.php @@ -0,0 +1,3 @@ + new TavilySearch, + 'TavilyExtract' => new TavilyExtract, + 'TavilyCrawl' => new TavilyCrawl, + 'TavilyMap' => new TavilyMap, + ]; + + $results = []; + + foreach ($tools as $name => $tool) { + $results[$name] = [ + 'description' => $tool->description(), + 'schema' => array_keys($tool->schema(app(JsonSchema::class))), + ]; + } + + return response()->json([ + 'message' => 'Toolkit Tavily tools are loaded.', + 'tools' => $results, + 'endpoints' => [ + 'GET /tavily/search?query=...' => 'TavilySearch', + 'GET /tavily/extract?urls=...' => 'TavilyExtract', + 'GET /tavily/crawl?url=...' => 'TavilyCrawl', + 'GET /tavily/map?url=...' => 'TavilyMap', + ], + 'note' => 'Set TAVILY_API_KEY in .env to test live API calls.', + ]); +}); + +Route::get('/tavily/search', function () { + $query = request('query', 'Laravel AI SDK'); + $tool = new TavilySearch; + $result = $tool->handle(new Request([ + 'query' => $query, + 'max_results' => request('max_results'), + 'search_depth' => request('search_depth'), + 'include_answer' => request('include_answer'), + ])); + + return response()->json([ + 'query' => $query, + 'result' => json_decode($result, true) ?? $result, + ]); +}); + +Route::get('/tavily/extract', function () { + $urls = request('urls', 'https://laravel.com'); + $tool = new TavilyExtract; + $result = $tool->handle(new Request([ + 'urls' => $urls, + 'query' => request('query'), + 'extract_depth' => request('extract_depth'), + 'format' => request('format'), + 'include_images' => request('include_images'), + ])); + + return response()->json([ + 'urls' => $urls, + 'result' => json_decode($result, true) ?? $result, + ]); +}); + +Route::get('/tavily/crawl', function () { + $url = request('url', 'https://laravel.com'); + $tool = new TavilyCrawl; + $result = $tool->handle(new Request([ + 'url' => $url, + 'instructions' => request('instructions'), + 'max_depth' => request('max_depth'), + 'max_breadth' => request('max_breadth'), + 'limit' => request('limit'), + 'extract_depth' => request('extract_depth'), + 'allow_external' => request('allow_external'), + ])); + + return response()->json([ + 'url' => $url, + 'result' => json_decode($result, true) ?? $result, + ]); +}); + +Route::get('/tavily/map', function () { + $url = request('url', 'https://laravel.com'); + $tool = new TavilyMap; + $result = $tool->handle(new Request([ + 'url' => $url, + 'instructions' => request('instructions'), + 'max_depth' => request('max_depth'), + 'max_breadth' => request('max_breadth'), + 'limit' => request('limit'), + 'allow_external' => request('allow_external'), + ])); + + return response()->json([ + 'url' => $url, + 'result' => json_decode($result, true) ?? $result, + ]); +});