diff --git a/apps/docs/content/docs/en/integrations/exa.mdx b/apps/docs/content/docs/en/integrations/exa.mdx index c136b4f51cd..509a74803c0 100644 --- a/apps/docs/content/docs/en/integrations/exa.mdx +++ b/apps/docs/content/docs/en/integrations/exa.mdx @@ -21,15 +21,18 @@ With Exa, you can: - **Find similar content**: Discover related resources based on content similarity - **Extract webpage contents**: Retrieve and process the full text of web pages - **Answer questions with citations**: Ask questions and receive direct answers with supporting sources -- **Perform research tasks**: Automate multi-step research workflows to gather, synthesize, and summarize information +- **Run deep research**: Use Exa Agent for multi-step research, list building, and enrichment +- **Return structured data**: Supply a JSON schema and get typed fields back with field-level citations -In Sim, the Exa integration allows your agents to search the web for information, retrieve content from specific URLs, find similar resources, answer questions with citations, and conduct research tasks—all programmatically through API calls. This enables your agents to access real-time information from the internet, enhancing their ability to provide accurate, current, and relevant responses. The integration is particularly valuable for research tasks, information gathering, content discovery, and answering questions that require up-to-date information from across the web. +In Sim, the Exa integration allows your agents to search the web for information, retrieve content from specific URLs, answer questions with citations, and run deep research with Exa Agent—all programmatically through API calls. This enables your agents to access real-time information from the internet, enhancing their ability to provide accurate, current, and relevant responses. The integration is particularly valuable for research tasks, information gathering, content discovery, and answering questions that require up-to-date information from across the web. + +**Migration notes.** Exa has retired its standalone Research endpoint — use the **Agent** operation for deep research. Workflows still configured with the old Research operation are routed to Agent automatically. Exa has also deprecated **Find Similar Links** in favor of Search, and `livecrawl` in favor of `maxAgeHours`. {/* MANUAL-CONTENT-END */} ## Usage Instructions -Integrate Exa into the workflow. Can search, get contents, find similar links, answer a question, and perform research. +Integrate Exa into the workflow. Can search the web, get page contents, find similar links, answer a question with citations, and run deep research with Exa Agent. @@ -44,20 +47,29 @@ Search the web using Exa AI. Returns relevant search results with titles, URLs, | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `query` | string | Yes | The search query to execute | -| `numResults` | number | No | Number of results to return \(e.g., 5, 10, 25\). Default: 10, max: 25 | -| `useAutoprompt` | boolean | No | Whether to use autoprompt to improve the query \(true or false\). Default: false | -| `type` | string | No | Search type: "neural", "keyword", "auto", or "fast". Default: "auto" | +| `numResults` | number | No | Number of results to return \(1-100\). Default: 10 | +| `type` | string | No | Search type: "instant", "fast", "auto", "deep-lite", "deep", or "deep-reasoning". Default: "auto" | | `includeDomains` | string | No | Comma-separated list of domains to include in results \(e.g., "github.com, stackoverflow.com"\) | | `excludeDomains` | string | No | Comma-separated list of domains to exclude from results \(e.g., "reddit.com, pinterest.com"\) | -| `category` | string | No | Filter by category: company, research paper, news, pdf, github, tweet, personal site, linkedin profile, financial report | +| `category` | string | No | Filter by category: company, publication, news, personal site, financial report, people | | `text` | boolean | No | Include full text content in results \(default: false\) | | `highlights` | boolean | No | Include highlighted snippets in results \(default: false\) | | `summary` | boolean | No | Include AI-generated summaries in results \(default: false\) | -| `livecrawl` | string | No | Live crawling mode: never \(default\), fallback, always, or preferred \(always try livecrawl, fall back to cache if fails\) | -| `startCrawlDate` | string | No | Only include results crawled on or after this ISO 8601 date \(e.g., "2024-01-01" or "2024-01-01T00:00:00.000Z"\) | -| `endCrawlDate` | string | No | Only include results crawled on or before this ISO 8601 date | -| `startPublishedDate` | string | No | Only include results published on or after this ISO 8601 date | +| `summaryQuery` | string | No | Query to focus the generated summaries on a specific question | +| `subpages` | number | No | Number of subpages to crawl per result \(0-100\). Default: 0 | +| `subpageTarget` | string | No | Comma-separated keywords to target specific subpages \(e.g., "docs,pricing,about"\) | +| `extrasLinks` | number | No | Number of links to extract from each result page \(0-1000\). Default: 0 | +| `extrasImageLinks` | number | No | Number of image URLs to extract from each result page \(0-1000\). Default: 0 | +| `outputSchema` | json | No | JSON Schema describing a synthesized answer to build from the results. Returned in structuredOutput. | +| `systemPrompt` | string | No | Additional guidance for generating the synthesized output | +| `userLocation` | string | No | Two-letter ISO country code to localize results \(e.g., "US"\) | +| `maxAgeHours` | number | No | Cache freshness in hours \(-1 to 720\). 0 always crawls live, -1 uses cache only. Cannot be combined with livecrawl. | +| `livecrawlTimeout` | number | No | Live crawl timeout in milliseconds \(max 90000\). Default: 10000 | +| `livecrawl` | string | No | Deprecated: use maxAgeHours instead. Live crawling mode: never, fallback, always, or preferred | +| `startPublishedDate` | string | No | Only include results published on or after this ISO 8601 date \(e.g., "2024-01-01" or "2024-01-01T00:00:00.000Z"\) | | `endPublishedDate` | string | No | Only include results published on or before this ISO 8601 date | +| `startCrawlDate` | string | No | Deprecated: use startPublishedDate. Only include results crawled on or after this ISO 8601 date | +| `endCrawlDate` | string | No | Deprecated: use endPublishedDate. Only include results crawled on or before this ISO 8601 date | | `apiKey` | string | Yes | Exa AI API Key | | `pricing` | custom | No | No description | | `rateLimit` | string | No | No description | @@ -67,6 +79,7 @@ Search the web using Exa AI. Returns relevant search results with titles, URLs, | Parameter | Type | Description | | --------- | ---- | ----------- | | `results` | array | Search results with titles, URLs, and text snippets | +| ↳ `id` | string | Result identifier, usable as an id on the Get Contents operation | | ↳ `title` | string | The title of the search result | | ↳ `url` | string | The URL of the search result | | ↳ `publishedDate` | string | Date when the content was published | @@ -75,7 +88,15 @@ Search the web using Exa AI. Returns relevant search results with titles, URLs, | ↳ `favicon` | string | URL of the site's favicon | | ↳ `image` | string | URL of a representative image from the page | | ↳ `text` | string | Text snippet or full content from the page | -| ↳ `score` | number | Relevance score for the search result | +| ↳ `highlights` | array | Relevant snippets extracted from the page | +| ↳ `highlightScores` | array | Similarity score for each highlight | +| ↳ `subpages` | json | Crawled subpages of the result | +| ↳ `entities` | json | Structured entity data for company, people, and publication results | +| ↳ `extras` | json | Extracted links and image links when requested | +| ↳ `score` | number | Relevance score. Only returned by the legacy neural search type | +| `requestId` | string | Exa request identifier, useful for support | +| `structuredOutput` | json | Synthesized answer matching outputSchema, when one was supplied | +| `grounding` | json | Field-level citations backing the synthesized output | ### `exa_get_contents` @@ -85,13 +106,19 @@ Retrieve the contents of webpages using Exa AI. Returns the title, text content, | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `urls` | string | Yes | Comma-separated list of URLs to retrieve content from | +| `urls` | string | No | Comma-separated list of URLs to retrieve content from \(1-100\). Provide either urls or ids, not both. | +| `ids` | string | No | Comma-separated list of result IDs from a prior Exa search \(1-100\). Provide either urls or ids, not both. | | `text` | boolean | No | If true, returns full page text with default settings. If false, disables text return. | +| `summary` | boolean | No | Include an AI-generated summary of each page \(default: false\) | | `summaryQuery` | string | No | Query to guide the summary generation | -| `subpages` | number | No | Number of subpages to crawl from the provided URLs | +| `subpages` | number | No | Number of subpages to crawl from the provided URLs \(0-100\) | | `subpageTarget` | string | No | Comma-separated keywords to target specific subpages \(e.g., "docs,tutorial,about"\) | | `highlights` | boolean | No | Include highlighted snippets in results \(default: false\) | -| `livecrawl` | string | No | Live crawling mode: never \(default\), fallback, always, or preferred \(always try livecrawl, fall back to cache if fails\) | +| `extrasLinks` | number | No | Number of links to extract from each page \(0-1000\). Default: 0 | +| `extrasImageLinks` | number | No | Number of image URLs to extract from each page \(0-1000\). Default: 0 | +| `maxAgeHours` | number | No | Cache freshness in hours \(-1 to 720\). 0 always crawls live, -1 uses cache only. Cannot be combined with livecrawl. | +| `livecrawlTimeout` | number | No | Live crawl timeout in milliseconds \(max 90000\). Default: 10000 | +| `livecrawl` | string | No | Deprecated: use maxAgeHours instead. Live crawling mode: never, fallback, always, or preferred | | `apiKey` | string | Yes | Exa AI API Key | | `pricing` | custom | No | No description | | `rateLimit` | string | No | No description | @@ -101,28 +128,39 @@ Retrieve the contents of webpages using Exa AI. Returns the title, text content, | Parameter | Type | Description | | --------- | ---- | ----------- | | `results` | array | Retrieved content from URLs with title, text, and summaries | +| ↳ `id` | string | Exa identifier for the retrieved document | | ↳ `url` | string | The URL that content was retrieved from | | ↳ `title` | string | The title of the webpage | | ↳ `text` | string | The full text content of the webpage | | ↳ `summary` | string | AI-generated summary of the webpage content | +| ↳ `highlights` | array | Relevant snippets extracted from the page | +| ↳ `highlightScores` | array | Similarity score for each highlight | +| ↳ `subpages` | json | Crawled subpages of the document | +| ↳ `entities` | json | Structured entity data for company, people, and publication pages | +| ↳ `extras` | json | Extracted links and image links when requested | +| `statuses` | json | Per-URL crawl outcome, showing which pages succeeded and whether they came from cache | +| `requestId` | string | Exa request identifier, useful for support | ### `exa_find_similar_links` -Find webpages similar to a given URL using Exa AI. Returns a list of similar links with titles and text snippets. +Find webpages similar to a given URL using Exa AI. Deprecated by Exa in favor of Search — prefer Search for new workflows. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `url` | string | Yes | The URL to find similar links for | -| `numResults` | number | No | Number of similar links to return \(e.g., 5, 10, 25\). Default: 10, max: 25 | +| `numResults` | number | No | Number of similar links to return \(1-100\). Default: 10 | | `text` | boolean | No | Whether to include the full text of the similar pages | | `includeDomains` | string | No | Comma-separated list of domains to include in results \(e.g., "github.com, stackoverflow.com"\) | | `excludeDomains` | string | No | Comma-separated list of domains to exclude from results \(e.g., "reddit.com, pinterest.com"\) | | `excludeSourceDomain` | boolean | No | Exclude the source domain from results \(default: false\) | +| `category` | string | No | Filter by category: company, publication, news, personal site, financial report, people | | `highlights` | boolean | No | Include highlighted snippets in results \(default: false\) | | `summary` | boolean | No | Include AI-generated summaries in results \(default: false\) | -| `livecrawl` | string | No | Live crawling mode: never \(default\), fallback, always, or preferred \(always try livecrawl, fall back to cache if fails\) | +| `maxAgeHours` | number | No | Cache freshness in hours \(-1 to 720\). 0 always crawls live, -1 uses cache only. Cannot be combined with livecrawl. | +| `livecrawlTimeout` | number | No | Live crawl timeout in milliseconds \(max 90000\). Default: 10000 | +| `livecrawl` | string | No | Deprecated: use maxAgeHours instead. Live crawling mode: never, fallback, always, or preferred | | `apiKey` | string | Yes | Exa AI API Key | | `pricing` | custom | No | No description | | `rateLimit` | string | No | No description | @@ -132,10 +170,14 @@ Find webpages similar to a given URL using Exa AI. Returns a list of similar lin | Parameter | Type | Description | | --------- | ---- | ----------- | | `similarLinks` | array | Similar links found with titles, URLs, and text snippets | +| ↳ `id` | string | Exa identifier for the similar page | | ↳ `title` | string | The title of the similar webpage | | ↳ `url` | string | The URL of the similar webpage | | ↳ `text` | string | Text snippet or full content from the similar webpage | +| ↳ `summary` | string | AI-generated summary of the similar webpage | +| ↳ `highlights` | array | Relevant snippets extracted from the page | | ↳ `score` | number | Similarity score indicating how similar the page is | +| `requestId` | string | Exa request identifier, useful for support | ### `exa_answer` @@ -146,7 +188,8 @@ Get an AI-generated answer to a question with citations from the web using Exa A | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `query` | string | Yes | The question to answer | -| `text` | boolean | No | Whether to include the full text of the answer | +| `text` | boolean | No | Include the full page text of each cited source \(default: false\). This does not affect the answer itself. | +| `outputSchema` | json | No | JSON Schema describing the answer shape. When supplied, the answer is returned as a structured object instead of a string. | | `apiKey` | string | Yes | Exa AI API Key | | `pricing` | custom | No | No description | | `rateLimit` | string | No | No description | @@ -155,28 +198,43 @@ Get an AI-generated answer to a question with citations from the web using Exa A | Parameter | Type | Description | | --------- | ---- | ----------- | -| `answer` | string | AI-generated answer to the question | +| `answer` | json | AI-generated answer to the question. A string, or an object matching outputSchema when one was supplied. | | `citations` | array | Sources and citations for the answer | +| ↳ `id` | string | Exa identifier for the cited source | | ↳ `title` | string | The title of the cited source | | ↳ `url` | string | The URL of the cited source | -| ↳ `text` | string | Relevant text from the cited source | +| ↳ `text` | string | Full page text of the cited source, when text is enabled | +| ↳ `author` | string | The author of the cited source | +| ↳ `publishedDate` | string | Publication date of the cited source | +| `requestId` | string | Exa request identifier, useful for support | -### `exa_research` +### `exa_agent` -Perform comprehensive research using AI to generate detailed reports with citations +Run a deep research task with Exa Agent. Handles multi-step list building, enrichment, and research, returning a written answer with field-level citations and optional structured output. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `query` | string | Yes | Research query or topic | -| `model` | string | No | Research model: exa-research-fast, exa-research \(default\), or exa-research-pro | +| `query` | string | Yes | The research question or instructions for the agent | +| `effort` | string | No | Cost and depth tradeoff: minimal, low, medium, high, xhigh, or auto \(default: auto\) | +| `outputSchema` | json | No | JSON Schema describing the structured result to return. Returned in the structured output. | +| `systemPrompt` | string | No | Additional guidance for how the agent should behave or format its answer | +| `previousRunId` | string | No | ID of a completed agent run to continue from, for follow-up questions | | `apiKey` | string | Yes | Exa AI API Key | +| `pricing` | custom | No | No description | +| `rateLimit` | string | No | No description | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `research` | array | Comprehensive research findings with citations and summaries | +| `runId` | string | Identifier of the agent run, reusable as previousRunId | +| `status` | string | Final status of the agent run | +| `stopReason` | string | Why the agent stopped, such as schema_satisfied | +| `text` | string | The written answer produced by the agent | +| `structured` | json | Structured result matching outputSchema, when one was supplied | +| `grounding` | json | Field-level citations backing the agent output | +| `research` | array | The agent answer in the shape the retired Research operation emitted, so workflows that reference it keep resolving | diff --git a/apps/sim/blocks/blocks/exa.ts b/apps/sim/blocks/blocks/exa.ts index ec016a6ae04..60c87c8c172 100644 --- a/apps/sim/blocks/blocks/exa.ts +++ b/apps/sim/blocks/blocks/exa.ts @@ -3,13 +3,45 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import type { ExaResponse } from '@/tools/exa/types' +/** Categories Exa currently supports. Shared by Search and Find Similar Links. */ +const CATEGORY_OPTIONS = [ + { label: 'None', id: '' }, + { label: 'Company', id: 'company' }, + { label: 'Publication', id: 'publication' }, + { label: 'News', id: 'news' }, + { label: 'Personal Site', id: 'personal site' }, + { label: 'Financial Report', id: 'financial report' }, + { label: 'People', id: 'people' }, +] + +/** + * Exa retired `/research/v1` (HTTP 410) and replaced it with the Agent API. + * Workflows saved against the old Research operation are routed to the Agent + * tool so they keep running instead of failing against a dead endpoint. The + * Agent operation's inputs are conditioned on both ids so the serializer keeps + * carrying a stored research query forward — it drops any value whose sub-block + * condition no longer matches. + */ +const LEGACY_RESEARCH_OPERATION = 'exa_research' +const AGENT_OPERATIONS = ['exa_agent', LEGACY_RESEARCH_OPERATION] + +/** + * Maps the retired research models onto the Agent API's effort levels, so a + * workflow that asked for a deeper (or cheaper) run still gets one. + */ +const RESEARCH_MODEL_TO_EFFORT: Record = { + 'exa-research-fast': 'low', + 'exa-research': 'medium', + 'exa-research-pro': 'high', +} + export const ExaBlock: BlockConfig = { type: 'exa', name: 'Exa', description: 'Search with Exa AI', authMode: AuthMode.ApiKey, longDescription: - 'Integrate Exa into the workflow. Can search, get contents, find similar links, answer a question, and perform research.', + 'Integrate Exa into the workflow. Can search the web, get page contents, find similar links, answer a question with citations, and run deep research with Exa Agent.', docsLink: 'https://docs.sim.ai/integrations/exa', category: 'tools', integrationType: IntegrationType.Search, @@ -24,9 +56,9 @@ export const ExaBlock: BlockConfig = { options: [ { label: 'Search', id: 'exa_search' }, { label: 'Get Contents', id: 'exa_get_contents' }, - { label: 'Find Similar Links', id: 'exa_find_similar_links' }, { label: 'Answer', id: 'exa_answer' }, - { label: 'Research', id: 'exa_research' }, + { label: 'Agent', id: 'exa_agent' }, + { label: 'Find Similar Links', id: 'exa_find_similar_links' }, ], value: () => 'exa_search', }, @@ -46,22 +78,17 @@ export const ExaBlock: BlockConfig = { placeholder: '10', condition: { field: 'operation', value: 'exa_search' }, }, - { - id: 'useAutoprompt', - title: 'Use Autoprompt', - type: 'switch', - condition: { field: 'operation', value: 'exa_search' }, - mode: 'advanced', - }, { id: 'type', title: 'Search Type', type: 'dropdown', options: [ { label: 'Auto', id: 'auto' }, - { label: 'Neural', id: 'neural' }, - { label: 'Keyword', id: 'keyword' }, + { label: 'Instant', id: 'instant' }, { label: 'Fast', id: 'fast' }, + { label: 'Deep Lite', id: 'deep-lite' }, + { label: 'Deep', id: 'deep' }, + { label: 'Deep Reasoning', id: 'deep-reasoning' }, ], value: () => 'auto', condition: { field: 'operation', value: 'exa_search' }, @@ -87,18 +114,7 @@ export const ExaBlock: BlockConfig = { id: 'category', title: 'Category Filter', type: 'dropdown', - options: [ - { label: 'None', id: '' }, - { label: 'Company', id: 'company' }, - { label: 'Research Paper', id: 'research_paper' }, - { label: 'News Article', id: 'news_article' }, - { label: 'PDF', id: 'pdf' }, - { label: 'GitHub', id: 'github' }, - { label: 'Tweet', id: 'tweet' }, - { label: 'Movie', id: 'movie' }, - { label: 'Song', id: 'song' }, - { label: 'Personal Site', id: 'personal_site' }, - ], + options: CATEGORY_OPTIONS, value: () => '', condition: { field: 'operation', value: 'exa_search' }, mode: 'advanced', @@ -124,15 +140,86 @@ export const ExaBlock: BlockConfig = { mode: 'advanced', }, { - id: 'livecrawl', - title: 'Live Crawl Mode', - type: 'dropdown', - options: [ - { label: 'Never (default)', id: 'never' }, - { label: 'Fallback', id: 'fallback' }, - { label: 'Always', id: 'always' }, - ], - value: () => 'never', + id: 'summaryQuery', + title: 'Summary Query', + type: 'long-input', + placeholder: 'Focus the summaries on a specific question...', + condition: { field: 'operation', value: 'exa_search' }, + mode: 'advanced', + }, + { + id: 'subpages', + title: 'Number of Subpages', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'exa_search' }, + mode: 'advanced', + }, + { + id: 'subpageTarget', + title: 'Subpage Target Keywords', + type: 'long-input', + placeholder: 'docs, pricing, about (comma-separated)', + condition: { field: 'operation', value: 'exa_search' }, + mode: 'advanced', + }, + { + id: 'extrasLinks', + title: 'Extract Links Per Result', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'exa_search' }, + mode: 'advanced', + }, + { + id: 'extrasImageLinks', + title: 'Extract Image Links Per Result', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'exa_search' }, + mode: 'advanced', + }, + { + id: 'outputSchema', + title: 'Output Schema', + type: 'code', + language: 'json', + placeholder: '{\n "type": "object",\n "properties": {}\n}', + description: 'JSON Schema for a synthesized answer built from the results', + condition: { field: 'operation', value: 'exa_search' }, + mode: 'advanced', + }, + { + id: 'systemPrompt', + title: 'System Prompt', + type: 'long-input', + placeholder: 'Guidance for generating the synthesized output...', + condition: { field: 'operation', value: 'exa_search' }, + mode: 'advanced', + }, + { + id: 'userLocation', + title: 'User Location', + type: 'short-input', + placeholder: 'US', + description: 'Two-letter ISO country code used to localize results', + condition: { field: 'operation', value: 'exa_search' }, + mode: 'advanced', + }, + { + id: 'maxAgeHours', + title: 'Max Content Age (Hours)', + type: 'short-input', + placeholder: '24', + description: '-1 uses cache only, 0 always crawls live, 1-720 crawls when cache is older', + condition: { field: 'operation', value: 'exa_search' }, + mode: 'advanced', + }, + { + id: 'livecrawlTimeout', + title: 'Live Crawl Timeout (ms)', + type: 'short-input', + placeholder: '10000', condition: { field: 'operation', value: 'exa_search' }, mode: 'advanced', }, @@ -154,17 +241,19 @@ export const ExaBlock: BlockConfig = { }, { id: 'startCrawlDate', - title: 'Start Crawl Date', + title: 'Start Crawl Date (Deprecated)', type: 'short-input', placeholder: '2024-01-01 or 2024-01-01T00:00:00.000Z', + description: 'Deprecated by Exa. Prefer Start Published Date.', condition: { field: 'operation', value: 'exa_search' }, mode: 'advanced', }, { id: 'endCrawlDate', - title: 'End Crawl Date', + title: 'End Crawl Date (Deprecated)', type: 'short-input', placeholder: '2024-12-31 or 2024-12-31T23:59:59.999Z', + description: 'Deprecated by Exa. Prefer End Published Date.', condition: { field: 'operation', value: 'exa_search' }, mode: 'advanced', }, @@ -174,15 +263,39 @@ export const ExaBlock: BlockConfig = { title: 'URLs', type: 'long-input', placeholder: 'Enter URLs to retrieve content from (comma-separated)...', + description: 'Provide either URLs or Result IDs, not both', condition: { field: 'operation', value: 'exa_get_contents' }, - required: true, }, + { + id: 'ids', + title: 'Result IDs', + type: 'long-input', + placeholder: 'IDs from a previous Exa search (comma-separated)...', + description: 'Provide either URLs or Result IDs, not both', + condition: { field: 'operation', value: 'exa_get_contents' }, + mode: 'advanced', + }, + /* + * URLs and Result IDs are mutually exclusive alternate identifiers, so both + * stay optional and the request body enforces exactly one. A conditional + * `required` cannot express this: `isFieldRequired` in webhook deploy calls + * the callback with no arguments, so it would mark URLs missing on a valid + * ids-only block, and `collectBlockFieldIssues` skips sub-block required + * checks whose id matches a tool param, so it would never run there anyway. + */ { id: 'text', title: 'Include Text', type: 'switch', condition: { field: 'operation', value: 'exa_get_contents' }, }, + { + id: 'summary', + title: 'Include Summary', + type: 'switch', + condition: { field: 'operation', value: 'exa_get_contents' }, + mode: 'advanced', + }, { id: 'summaryQuery', title: 'Summary Query', @@ -214,6 +327,130 @@ export const ExaBlock: BlockConfig = { condition: { field: 'operation', value: 'exa_get_contents' }, mode: 'advanced', }, + { + id: 'extrasLinks', + title: 'Extract Links Per Page', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'exa_get_contents' }, + mode: 'advanced', + }, + { + id: 'extrasImageLinks', + title: 'Extract Image Links Per Page', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'exa_get_contents' }, + mode: 'advanced', + }, + { + id: 'maxAgeHours', + title: 'Max Content Age (Hours)', + type: 'short-input', + placeholder: '24', + description: '-1 uses cache only, 0 always crawls live, 1-720 crawls when cache is older', + condition: { field: 'operation', value: 'exa_get_contents' }, + mode: 'advanced', + }, + { + id: 'livecrawlTimeout', + title: 'Live Crawl Timeout (ms)', + type: 'short-input', + placeholder: '10000', + condition: { field: 'operation', value: 'exa_get_contents' }, + mode: 'advanced', + }, + // Answer operation inputs + { + id: 'query', + title: 'Question', + type: 'long-input', + placeholder: 'Enter your question...', + condition: { field: 'operation', value: 'exa_answer' }, + required: true, + }, + { + id: 'text', + title: 'Include Source Text', + type: 'switch', + description: 'Include the full page text of each cited source', + condition: { field: 'operation', value: 'exa_answer' }, + mode: 'advanced', + }, + { + id: 'outputSchema', + title: 'Output Schema', + type: 'code', + language: 'json', + placeholder: '{\n "type": "object",\n "properties": {}\n}', + description: 'JSON Schema that turns the answer into a structured object', + condition: { field: 'operation', value: 'exa_answer' }, + mode: 'advanced', + }, + // Agent operation inputs + { + id: 'query', + title: 'Research Query', + type: 'long-input', + placeholder: 'Enter your research topic or question...', + condition: { field: 'operation', value: AGENT_OPERATIONS }, + required: true, + }, + { + id: 'effort', + title: 'Effort', + type: 'dropdown', + options: [ + { label: 'Auto (default)', id: 'auto' }, + { label: 'Minimal', id: 'minimal' }, + { label: 'Low', id: 'low' }, + { label: 'Medium', id: 'medium' }, + { label: 'High', id: 'high' }, + { label: 'Extra High', id: 'xhigh' }, + ], + value: () => 'auto', + condition: { field: 'operation', value: AGENT_OPERATIONS }, + }, + { + id: 'outputSchema', + title: 'Output Schema', + type: 'code', + language: 'json', + placeholder: '{\n "type": "object",\n "properties": {}\n}', + description: 'JSON Schema describing the structured result to return', + condition: { field: 'operation', value: AGENT_OPERATIONS }, + mode: 'advanced', + }, + { + id: 'systemPrompt', + title: 'System Prompt', + type: 'long-input', + placeholder: 'Guidance for how the agent should behave...', + condition: { field: 'operation', value: AGENT_OPERATIONS }, + mode: 'advanced', + }, + { + id: 'model', + title: 'Research Model (Legacy)', + type: 'dropdown', + options: [ + { label: 'Standard', id: 'exa-research' }, + { label: 'Fast', id: 'exa-research-fast' }, + { label: 'Pro', id: 'exa-research-pro' }, + ], + description: 'Retired Exa research model, carried over as an Agent effort level', + value: () => 'exa-research', + condition: { field: 'operation', value: LEGACY_RESEARCH_OPERATION }, + }, + { + id: 'previousRunId', + title: 'Previous Run ID', + type: 'short-input', + placeholder: 'agent_run_...', + description: 'Continue from a completed agent run for follow-up questions', + condition: { field: 'operation', value: AGENT_OPERATIONS }, + mode: 'advanced', + }, // Find Similar Links operation inputs { id: 'url', @@ -263,18 +500,7 @@ export const ExaBlock: BlockConfig = { id: 'category', title: 'Category Filter', type: 'dropdown', - options: [ - { label: 'None', id: '' }, - { label: 'Company', id: 'company' }, - { label: 'Research Paper', id: 'research_paper' }, - { label: 'News Article', id: 'news_article' }, - { label: 'PDF', id: 'pdf' }, - { label: 'GitHub', id: 'github' }, - { label: 'Tweet', id: 'tweet' }, - { label: 'Movie', id: 'movie' }, - { label: 'Song', id: 'song' }, - { label: 'Personal Site', id: 'personal_site' }, - ], + options: CATEGORY_OPTIONS, value: () => '', condition: { field: 'operation', value: 'exa_find_similar_links' }, mode: 'advanced', @@ -294,56 +520,22 @@ export const ExaBlock: BlockConfig = { mode: 'advanced', }, { - id: 'livecrawl', - title: 'Live Crawl Mode', - type: 'dropdown', - options: [ - { label: 'Never (default)', id: 'never' }, - { label: 'Fallback', id: 'fallback' }, - { label: 'Always', id: 'always' }, - ], - value: () => 'never', + id: 'maxAgeHours', + title: 'Max Content Age (Hours)', + type: 'short-input', + placeholder: '24', + description: '-1 uses cache only, 0 always crawls live, 1-720 crawls when cache is older', condition: { field: 'operation', value: 'exa_find_similar_links' }, mode: 'advanced', }, - // Answer operation inputs { - id: 'query', - title: 'Question', - type: 'long-input', - placeholder: 'Enter your question...', - condition: { field: 'operation', value: 'exa_answer' }, - required: true, - }, - { - id: 'text', - title: 'Include Text', - type: 'switch', - condition: { field: 'operation', value: 'exa_answer' }, + id: 'livecrawlTimeout', + title: 'Live Crawl Timeout (ms)', + type: 'short-input', + placeholder: '10000', + condition: { field: 'operation', value: 'exa_find_similar_links' }, mode: 'advanced', }, - // Research operation inputs - { - id: 'query', - title: 'Research Query', - type: 'long-input', - placeholder: 'Enter your research topic or question...', - condition: { field: 'operation', value: 'exa_research' }, - required: true, - }, - { - id: 'model', - title: 'Research Model', - type: 'dropdown', - options: [ - { label: 'Standard (default)', id: 'exa-research' }, - { label: 'Fast', id: 'exa-research-fast' }, - { label: 'Pro', id: 'exa-research-pro' }, - ], - value: () => 'exa-research', - condition: { field: 'operation', value: 'exa_research' }, - }, - // API Key — hidden when hosted for operations with hosted key support { id: 'apiKey', title: 'API Key', @@ -352,27 +544,10 @@ export const ExaBlock: BlockConfig = { password: true, required: true, hideWhenHosted: true, - condition: { field: 'operation', value: 'exa_research', not: true }, - }, - // API Key — always visible for research (no hosted key support) - { - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your Exa API key', - password: true, - required: true, - condition: { field: 'operation', value: 'exa_research' }, }, ], tools: { - access: [ - 'exa_search', - 'exa_get_contents', - 'exa_find_similar_links', - 'exa_answer', - 'exa_research', - ], + access: ['exa_search', 'exa_get_contents', 'exa_find_similar_links', 'exa_answer', 'exa_agent'], config: { tool: (params) => { switch (params.operation) { @@ -384,8 +559,10 @@ export const ExaBlock: BlockConfig = { return 'exa_find_similar_links' case 'exa_answer': return 'exa_answer' - case 'exa_research': - return 'exa_research' + case 'exa_agent': + return 'exa_agent' + case LEGACY_RESEARCH_OPERATION: + return 'exa_agent' default: return 'exa_search' } @@ -398,6 +575,27 @@ export const ExaBlock: BlockConfig = { if (params.subpages) { result.subpages = Number(params.subpages) } + if (params.extrasLinks) { + result.extrasLinks = Number(params.extrasLinks) + } + if (params.extrasImageLinks) { + result.extrasImageLinks = Number(params.extrasImageLinks) + } + if (params.maxAgeHours !== undefined && String(params.maxAgeHours).trim() !== '') { + result.maxAgeHours = Number(params.maxAgeHours) + } + if (params.livecrawlTimeout) { + result.livecrawlTimeout = Number(params.livecrawlTimeout) + } + /** + * Carry a retired research model over to the Agent API's effort scale. + * The old Research operation defaulted to the standard model, so an + * unset value maps to the same depth rather than falling through to + * the Agent default of `auto`. + */ + if (params.operation === LEGACY_RESEARCH_OPERATION) { + result.effort = RESEARCH_MODEL_TO_EFFORT[params.model as string] ?? 'medium' + } return result }, }, @@ -408,7 +606,6 @@ export const ExaBlock: BlockConfig = { // Search operation query: { type: 'string', description: 'Search query terms' }, numResults: { type: 'number', description: 'Number of results' }, - useAutoprompt: { type: 'boolean', description: 'Use autoprompt feature' }, type: { type: 'string', description: 'Search type' }, includeDomains: { type: 'string', description: 'Include domains filter' }, excludeDomains: { type: 'string', description: 'Exclude domains filter' }, @@ -416,32 +613,76 @@ export const ExaBlock: BlockConfig = { text: { type: 'boolean', description: 'Include text content' }, highlights: { type: 'boolean', description: 'Include highlights' }, summary: { type: 'boolean', description: 'Include summary' }, - livecrawl: { type: 'string', description: 'Live crawl mode' }, - startCrawlDate: { type: 'string', description: 'Earliest crawl date (ISO 8601)' }, - endCrawlDate: { type: 'string', description: 'Latest crawl date (ISO 8601)' }, + summaryQuery: { type: 'string', description: 'Summary query guidance' }, + subpages: { type: 'number', description: 'Number of subpages to crawl' }, + subpageTarget: { type: 'string', description: 'Subpage target keywords' }, + extrasLinks: { type: 'number', description: 'Links to extract per page' }, + extrasImageLinks: { type: 'number', description: 'Image links to extract per page' }, + outputSchema: { type: 'json', description: 'JSON Schema for structured output' }, + systemPrompt: { type: 'string', description: 'Guidance for generated output' }, + userLocation: { type: 'string', description: 'Two-letter ISO country code' }, + maxAgeHours: { type: 'number', description: 'Cache freshness in hours' }, + livecrawlTimeout: { type: 'number', description: 'Live crawl timeout in milliseconds' }, startPublishedDate: { type: 'string', description: 'Earliest published date (ISO 8601)' }, endPublishedDate: { type: 'string', description: 'Latest published date (ISO 8601)' }, + startCrawlDate: { type: 'string', description: 'Earliest crawl date (ISO 8601, deprecated)' }, + endCrawlDate: { type: 'string', description: 'Latest crawl date (ISO 8601, deprecated)' }, // Get Contents operation urls: { type: 'string', description: 'URLs to retrieve' }, - summaryQuery: { type: 'string', description: 'Summary query guidance' }, - subpages: { type: 'number', description: 'Number of subpages to crawl' }, - subpageTarget: { type: 'string', description: 'Subpage target keywords' }, + ids: { type: 'string', description: 'Exa result IDs to retrieve' }, // Find Similar Links operation url: { type: 'string', description: 'Source URL' }, excludeSourceDomain: { type: 'boolean', description: 'Exclude source domain' }, - // Research operation - model: { type: 'string', description: 'Research model selection' }, + // Agent operation + effort: { type: 'string', description: 'Agent effort level' }, + model: { type: 'string', description: 'Retired research model, mapped to an effort level' }, + previousRunId: { type: 'string', description: 'Agent run to continue from' }, }, outputs: { - // Search output - results: { type: 'json', description: 'Search results' }, + // Search and Get Contents output + results: { + type: 'json', + description: + '[{id, title, url, publishedDate, author, summary, favicon, image, text, highlights, highlightScores, subpages, entities, extras}]', + }, + statuses: { + type: 'json', + description: 'Get Contents only. [{id, status, source, error}] — per-URL crawl outcome', + }, + structuredOutput: { + type: 'json', + description: 'Search only. Synthesized output matching the supplied output schema', + }, + grounding: { + type: 'json', + description: '[{field, citations, confidence}] — field-level citations for generated output', + }, + requestId: { type: 'string', description: 'Exa request identifier, useful for support' }, // Find Similar Links output - similarLinks: { type: 'json', description: 'Similar links found' }, + similarLinks: { + type: 'json', + description: '[{id, title, url, text, summary, highlights, score}]', + }, // Answer output - answer: { type: 'string', description: 'Generated answer' }, - citations: { type: 'json', description: 'Answer citations' }, - // Research output - research: { type: 'json', description: 'Research findings' }, + answer: { + type: 'json', + description: 'Generated answer — a string, or an object when an output schema is supplied', + }, + citations: { + type: 'json', + description: '[{id, title, url, text, author, publishedDate}]', + }, + // Agent output + runId: { type: 'string', description: 'Agent run identifier' }, + status: { type: 'string', description: 'Agent run status' }, + stopReason: { type: 'string', description: 'Why the agent stopped' }, + text: { type: 'string', description: 'Agent written answer' }, + structured: { type: 'json', description: 'Agent structured result' }, + research: { + type: 'json', + description: + '[{title, url, summary, text, score}] — the agent answer in the retired Research operation shape, so saved workflows keep resolving', + }, }, } @@ -453,7 +694,7 @@ export const ExaBlockMeta = { icon: ExaAIIcon, title: 'Exa company intel agent', prompt: - 'Build an agent that takes a company name, uses Exa neural search to find recent product updates, funding news, and competitor mentions, and writes a one-page intel brief.', + 'Build an agent that takes a company name, uses the Exa Agent operation to find recent product updates, funding news, and competitor mentions, and writes a one-page intel brief.', modules: ['agent', 'files', 'workflows'], category: 'sales', tags: ['sales', 'research'], @@ -469,9 +710,9 @@ export const ExaBlockMeta = { }, { icon: ExaAIIcon, - title: 'Exa neural research agent', + title: 'Exa deep research agent', prompt: - 'Build an agent that uses Exa neural search to find authoritative sources on a topic, scrapes them, and produces a structured research brief with citations.', + 'Build an agent that uses Exa deep search to find authoritative sources on a topic, scrapes them, and produces a structured research brief with citations.', modules: ['agent', 'files', 'workflows'], category: 'productivity', tags: ['research'], @@ -480,7 +721,7 @@ export const ExaBlockMeta = { icon: ExaAIIcon, title: 'Exa similar-page finder', prompt: - 'Create a workflow that takes a URL, runs Exa similar-page search to find related authoritative sources, and writes the discovery list to a research table.', + 'Create a workflow that takes a URL, runs an Exa search to find related authoritative sources, and writes the discovery list to a research table.', modules: ['tables', 'agent', 'workflows'], category: 'marketing', tags: ['marketing', 'research'], @@ -499,7 +740,7 @@ export const ExaBlockMeta = { icon: ExaAIIcon, title: 'Exa investment research helper', prompt: - 'Create an agent that uses Exa to deep-research a ticker, finds recent material developments, summarizes with citations, and writes the brief to a finance research file.', + 'Create an agent that uses the Exa Agent operation to deep-research a ticker, finds recent material developments, summarizes with citations, and writes the brief to a finance research file.', modules: ['agent', 'files', 'workflows'], category: 'operations', tags: ['finance', 'research'], @@ -518,28 +759,28 @@ export const ExaBlockMeta = { skills: [ { name: 'search-the-web-with-exa', - description: - 'Run an Exa neural or keyword search to find high-quality web sources on a topic.', + description: 'Run an Exa search to find high-quality web sources on a topic.', content: - '# Search the Web with Exa\n\nFind authoritative web pages on a topic using Exa AI search.\n\n## Steps\n1. Use the Search operation with a clear query. Pick the search type — neural for meaning-based discovery, keyword for exact terms, or auto to let Exa decide.\n2. Narrow results with include/exclude domains, a category filter (research paper, news article, company, GitHub), and published-date bounds for recency.\n3. Enable include-text or include-summary so each result comes back with usable content rather than just a link.\n\n## Output\nReturn the top results with title, URL, published date, and the text or summary. Note which filters were applied so the search can be tightened or broadened.', + '# Search the Web with Exa\n\nFind authoritative web pages on a topic using Exa AI search.\n\n## Steps\n1. Use the Search operation with a clear query. Pick the search type — auto lets Exa decide, instant and fast favor latency, and deep, deep-lite, or deep-reasoning spend more time for harder questions.\n2. Narrow results with include/exclude domains, a category filter (company, publication, news, personal site, financial report, people), and published-date bounds for recency.\n3. Enable include-text or include-summary so each result comes back with usable content rather than just a link. Set max content age to control how fresh the crawled content must be.\n4. To get a synthesized answer instead of a result list, supply an output schema and read structuredOutput.\n\n## Output\nReturn the top results with title, URL, published date, and the text or summary. Note which filters were applied so the search can be tightened or broadened.', }, { name: 'answer-question-with-citations', description: 'Use Exa Answer to get a direct, sourced answer to a factual question.', content: - '# Answer Question with Citations\n\nGet a grounded answer to a question with supporting sources via Exa.\n\n## Steps\n1. Use the Answer operation and pass the question in natural language.\n2. Enable include-text when you want the supporting passages, not just the citation URLs.\n3. Review the citations to confirm the answer is well-supported before relying on it.\n\n## Output\nReturn the answer text plus its citations (titles and URLs). If the citations are weak or conflicting, say so and recommend a follow-up search.', + '# Answer Question with Citations\n\nGet a grounded answer to a question with supporting sources via Exa.\n\n## Steps\n1. Use the Answer operation and pass the question in natural language.\n2. Enable include-source-text when you want each citation to carry its full page text, not just the URL.\n3. Supply an output schema when you need the answer as structured fields rather than prose.\n4. Review the citations to confirm the answer is well-supported before relying on it.\n\n## Output\nReturn the answer plus its citations (titles and URLs). If the citations are weak or conflicting, say so and recommend a follow-up search.', }, { name: 'extract-page-contents', description: 'Use Exa Get Contents to pull clean text and summaries from a set of URLs.', content: - '# Extract Page Contents\n\nRetrieve readable content from specific web pages using Exa.\n\n## Steps\n1. Use the Get Contents operation with the target URLs (comma-separated).\n2. Enable include-text for full content, and supply a summary query to get a focused summary tailored to what you need.\n3. To pull deeper context from a site, set a subpage count and target keywords (e.g., docs, pricing, about).\n\n## Output\nReturn each URL with its extracted text or summary and any highlights. Flag any URL that could not be crawled.', + '# Extract Page Contents\n\nRetrieve readable content from specific web pages using Exa.\n\n## Steps\n1. Use the Get Contents operation with the target URLs (comma-separated), or pass result IDs carried over from a previous Exa search.\n2. Enable include-text for full content, and supply a summary query to get a focused summary tailored to what you need.\n3. To pull deeper context from a site, set a subpage count and target keywords (e.g., docs, pricing, about).\n4. Set max content age to 0 when the page must be crawled live rather than served from cache.\n\n## Output\nReturn each URL with its extracted text or summary and any highlights. Check statuses and flag any URL that could not be crawled.', }, { - name: 'find-similar-pages', - description: 'Use Exa Find Similar Links to discover pages related to a known URL.', + name: 'run-deep-research-with-exa', + description: + 'Use the Exa Agent operation for multi-step research, list building, and enrichment.', content: - '# Find Similar Pages\n\nDiscover sources similar to a reference page using Exa.\n\n## Steps\n1. Use the Find Similar Links operation with the source URL.\n2. Set the number of results and enable exclude-source-domain so you get genuinely new sources, not more pages from the same site.\n3. Apply a category filter or include/exclude domains to keep the discovery on-target, and enable include-text or include-summary for context.\n\n## Output\nReturn the similar pages with title, URL, and a snippet or summary, ordered by relevance. Note the filters used.', + '# Run Deep Research with Exa\n\nAnswer a question that needs many searches and cross-referencing, using the Exa Agent operation.\n\n## Steps\n1. Use the Agent operation and write the query as a full instruction, not a keyword phrase — say what to find and what to report.\n2. Pick an effort level: minimal or low for quick lookups, medium for normal research, high or xhigh for exhaustive list building. Auto lets Exa choose.\n3. Supply an output schema when you need rows or fields back rather than prose; the result arrives in the structured output alongside field-level citations.\n4. To ask a follow-up against the same research, pass the previous run ID.\n\n## Output\nReturn the agent text answer, the structured result when a schema was used, and the grounding citations. Agent runs take longer than a search — expect seconds to minutes depending on effort.', }, ], } as const satisfies BlockMeta diff --git a/apps/sim/lib/copilot/tools/server/other/search-online.ts b/apps/sim/lib/copilot/tools/server/other/search-online.ts index 6448089a2e4..4c5ffc36b42 100644 --- a/apps/sim/lib/copilot/tools/server/other/search-online.ts +++ b/apps/sim/lib/copilot/tools/server/other/search-online.ts @@ -48,6 +48,9 @@ export const searchOnlineServerTool: BaseServerTool } @@ -68,7 +72,7 @@ export const searchOnlineServerTool: BaseServerTool ({ title: result.title ?? '', link: result.url ?? '', - snippet: result.text ?? result.summary ?? '', + snippet: result.highlights?.join(' ') || result.text || result.summary || '', date: result.publishedDate, position: index + 1, })) diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 8908eefeb49..e4571b0b9b7 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-07-29", + "updatedAt": "2026-07-30", "integrations": [ { "type": "onepassword", @@ -5929,7 +5929,7 @@ "slug": "exa", "name": "Exa", "description": "Search with Exa AI", - "longDescription": "Integrate Exa into the workflow. Can search, get contents, find similar links, answer a question, and perform research.", + "longDescription": "Integrate Exa into the workflow. Can search the web, get page contents, find similar links, answer a question with citations, and run deep research with Exa Agent.", "bgColor": "#1F40ED", "iconName": "ExaAIIcon", "docsUrl": "https://docs.sim.ai/integrations/exa", @@ -5942,17 +5942,17 @@ "name": "Get Contents", "description": "Retrieve the contents of webpages using Exa AI. Returns the title, text content, and optional summaries for each URL." }, - { - "name": "Find Similar Links", - "description": "Find webpages similar to a given URL using Exa AI. Returns a list of similar links with titles and text snippets." - }, { "name": "Answer", "description": "Get an AI-generated answer to a question with citations from the web using Exa AI." }, { - "name": "Research", - "description": "Perform comprehensive research using AI to generate detailed reports with citations" + "name": "Agent", + "description": "Run a deep research task with Exa Agent. Handles multi-step list building, enrichment, and research, returning a written answer with field-level citations and optional structured output." + }, + { + "name": "Find Similar Links", + "description": "Find webpages similar to a given URL using Exa AI. Deprecated by Exa in favor of Search — prefer Search for new workflows." } ], "operationCount": 5, diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index db8a1bea308..86e6cd99723 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -65,6 +65,18 @@ export const SUBBLOCK_ID_MIGRATIONS: Record> = { stage_ids: '_removed_stage_ids', owner_ids: '_removed_owner_ids', }, + exa: { + /** + * Exa deprecated both fields. `useAutoprompt` is gone from the API, and + * `livecrawl` is superseded by `maxAgeHours` — but their values are not + * interchangeable (`livecrawl` is a mode string, `maxAgeHours` a number), + * so mapping one onto the other would send `NaN`. Dropping `livecrawl` is + * also the fix for the block having defaulted it to `never`, which pinned + * every saved search to cached results. + */ + useAutoprompt: '_removed_useAutoprompt', + livecrawl: '_removed_livecrawl', + }, rippling: { action: '_removed_action', candidateDepartment: '_removed_candidateDepartment', diff --git a/apps/sim/tools/exa/agent.ts b/apps/sim/tools/exa/agent.ts new file mode 100644 index 00000000000..bd46276d0e1 --- /dev/null +++ b/apps/sim/tools/exa/agent.ts @@ -0,0 +1,284 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' +import type { ExaAgentParams, ExaAgentResponse } from '@/tools/exa/types' +import { parseJsonSchema, requireCostTotal } from '@/tools/exa/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ExaAgentTool') + +const POLL_INTERVAL_MS = 3000 +const MAX_POLL_TIME_MS = DEFAULT_EXECUTION_TIMEOUT_MS + +const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']) + +export const agentTool: ToolConfig = { + id: 'exa_agent', + name: 'Exa Agent', + description: + 'Run a deep research task with Exa Agent. Handles multi-step list building, enrichment, and research, returning a written answer with field-level citations and optional structured output.', + version: '1.0.0', + + params: { + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The research question or instructions for the agent', + }, + effort: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'Cost and depth tradeoff: minimal, low, medium, high, xhigh, or auto (default: auto)', + }, + outputSchema: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'JSON Schema describing the structured result to return. Returned in the structured output.', + }, + systemPrompt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Additional guidance for how the agent should behave or format its answer', + }, + previousRunId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ID of a completed agent run to continue from, for follow-up questions', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Exa AI API Key', + }, + }, + hosting: { + envKeyPrefix: 'EXA_API_KEY', + apiKeyParam: 'apiKey', + byokProviderId: 'exa', + pricing: { + type: 'custom', + getCost: (_params, output) => { + const cost = requireCostTotal(output, 'agent') + return { cost, metadata: { costDollars: output.__costDollars } } + }, + }, + rateLimit: { + mode: 'per_request', + requestsPerMinute: 5, + }, + }, + + request: { + url: 'https://api.exa.ai/agent/runs', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'x-api-key': params.apiKey, + }), + body: (params) => { + const body: Record = { + query: params.query, + } + + if (params.effort) body.effort = params.effort + if (params.systemPrompt) body.systemPrompt = params.systemPrompt + if (params.previousRunId) body.previousRunId = params.previousRunId + + const outputSchema = parseJsonSchema(params.outputSchema, 'outputSchema') + if (outputSchema) body.outputSchema = outputSchema + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + return { + success: true, + output: { + runId: data.id, + status: data.status, + stopReason: data.stopReason, + text: data.output?.text ?? '', + structured: data.output?.structured ?? undefined, + grounding: data.output?.grounding, + __costDollars: data.costDollars, + }, + } + }, + + /** + * Agent runs are asynchronous: the create call returns immediately with a + * `queued` or `running` status, so poll the run until it reaches a terminal + * status before handing results back to the workflow. + */ + postProcess: async (result, params) => { + if (!result.success) return result + + const runId = result.output.runId + if (!runId) { + return { ...result, success: false, error: 'Exa agent run did not return a run ID' } + } + + /** A run can already be terminal on creation, including a failed one. */ + if (TERMINAL_STATUSES.has(result.output.status ?? '')) { + return settle(result) + } + + logger.info(`Exa agent run ${runId} created, polling for completion`) + + let elapsedTime = 0 + + while (elapsedTime < MAX_POLL_TIME_MS) { + await sleep(POLL_INTERVAL_MS) + elapsedTime += POLL_INTERVAL_MS + + try { + const statusResponse = await fetch(`https://api.exa.ai/agent/runs/${runId}`, { + method: 'GET', + headers: { + 'x-api-key': params.apiKey, + 'Content-Type': 'application/json', + }, + }) + + if (!statusResponse.ok) { + throw new Error(`Failed to get agent run status: ${statusResponse.statusText}`) + } + + const runData = await statusResponse.json() + + if (!TERMINAL_STATUSES.has(runData.status)) continue + + result.output = { + runId, + status: runData.status, + stopReason: runData.stopReason, + text: runData.output?.text ?? '', + structured: runData.output?.structured ?? undefined, + grounding: runData.output?.grounding, + __costDollars: runData.costDollars, + } + + return settle(result) + } catch (error) { + logger.error('Error polling Exa agent run status', { + message: getErrorMessage(error, 'Unknown error'), + runId, + }) + + return { + ...result, + success: false, + error: `Error polling Exa agent run status: ${getErrorMessage(error, 'Unknown error')}`, + } + } + } + + logger.warn( + `Exa agent run ${runId} did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)` + ) + return { + ...result, + success: false, + error: `Exa agent run did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)`, + } + }, + + outputs: { + runId: { + type: 'string', + description: 'Identifier of the agent run, reusable as previousRunId', + }, + status: { type: 'string', description: 'Final status of the agent run' }, + stopReason: { + type: 'string', + description: 'Why the agent stopped, such as schema_satisfied', + nullable: true, + }, + text: { type: 'string', description: 'The written answer produced by the agent' }, + structured: { + type: 'json', + description: 'Structured result matching outputSchema, when one was supplied', + optional: true, + }, + grounding: { + type: 'json', + description: 'Field-level citations backing the agent output', + optional: true, + }, + research: { + type: 'array', + description: + 'The agent answer in the shape the retired Research operation emitted, so workflows that reference it keep resolving', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + url: { type: 'string' }, + summary: { type: 'string' }, + text: { type: 'string' }, + score: { type: 'number' }, + }, + }, + }, + }, +} + +/** + * Resolves a terminal run into a tool result. + * + * A run can reach a terminal status either on creation or while polling, and a + * `failed` or `cancelled` run must surface as a tool failure from both paths — + * routing them through here keeps the two in step. + */ +function settle(result: ExaAgentResponse): ExaAgentResponse { + const { status, stopReason } = result.output + + if (status !== 'completed') { + return { + ...result, + success: false, + error: `Exa agent run ${status}${stopReason ? `: ${stopReason}` : ''}`, + } + } + + /** + * A run that satisfies its schema can finish with an empty `text` body, so + * fall back to the structured payload rather than returning a blank answer. + */ + if (!result.output.text && result.output.structured !== undefined) { + result.output.text = JSON.stringify(result.output.structured, null, 2) + } + + result.output.research = buildLegacyResearchOutput(result.output.text) + + return result +} + +/** + * Mirrors the one-element array the retired Research operation returned. Saved + * workflows routed here from `exa_research` reference `research[0].text` and + * `research[0].summary`, which would otherwise resolve to undefined. + */ +function buildLegacyResearchOutput(text: string) { + return [ + { + title: 'Research Complete', + url: '', + summary: text, + text, + score: 1, + }, + ] +} diff --git a/apps/sim/tools/exa/answer.ts b/apps/sim/tools/exa/answer.ts index 7990f57ec9f..426e0a5cdbb 100644 --- a/apps/sim/tools/exa/answer.ts +++ b/apps/sim/tools/exa/answer.ts @@ -1,11 +1,12 @@ import type { ExaAnswerParams, ExaAnswerResponse } from '@/tools/exa/types' +import { parseJsonSchema, requireCostTotal } from '@/tools/exa/utils' import type { ToolConfig } from '@/tools/types' export const answerTool: ToolConfig = { id: 'exa_answer', name: 'Exa Answer', description: 'Get an AI-generated answer to a question with citations from the web using Exa AI.', - version: '1.0.0', + version: '2.0.0', params: { query: { @@ -18,7 +19,15 @@ export const answerTool: ToolConfig = { type: 'boolean', required: false, visibility: 'user-only', - description: 'Whether to include the full text of the answer', + description: + 'Include the full page text of each cited source (default: false). This does not affect the answer itself.', + }, + outputSchema: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'JSON Schema describing the answer shape. When supplied, the answer is returned as a structured object instead of a string.', }, apiKey: { type: 'string', @@ -34,11 +43,8 @@ export const answerTool: ToolConfig = { pricing: { type: 'custom', getCost: (_params, output) => { - const costDollars = output.__costDollars as { total?: number } | undefined - if (costDollars?.total == null) { - throw new Error('Exa answer response missing costDollars field') - } - return { cost: costDollars.total, metadata: { costDollars } } + const cost = requireCostTotal(output, 'answer') + return { cost, metadata: { costDollars: output.__costDollars } } }, }, rateLimit: { @@ -59,9 +65,11 @@ export const answerTool: ToolConfig = { query: params.query, } - // Add optional parameters if provided if (params.text) body.text = params.text + const outputSchema = parseJsonSchema(params.outputSchema, 'outputSchema') + if (outputSchema) body.outputSchema = outputSchema + return body }, }, @@ -72,14 +80,17 @@ export const answerTool: ToolConfig = { return { success: true, output: { - query: data.query || '', - answer: data.answer || '', + answer: data.answer ?? '', citations: data.citations?.map((citation: any) => ({ + id: citation.id, title: citation.title || '', url: citation.url, text: citation.text || '', + author: citation.author, + publishedDate: citation.publishedDate, })) || [], + requestId: data.requestId, __costDollars: data.costDollars, }, } @@ -87,8 +98,9 @@ export const answerTool: ToolConfig = { outputs: { answer: { - type: 'string', - description: 'AI-generated answer to the question', + type: 'json', + description: + 'AI-generated answer to the question. A string, or an object matching outputSchema when one was supplied.', }, citations: { type: 'array', @@ -96,11 +108,18 @@ export const answerTool: ToolConfig = { items: { type: 'object', properties: { + id: { type: 'string', description: 'Exa identifier for the cited source' }, title: { type: 'string', description: 'The title of the cited source' }, url: { type: 'string', description: 'The URL of the cited source' }, - text: { type: 'string', description: 'Relevant text from the cited source' }, + text: { + type: 'string', + description: 'Full page text of the cited source, when text is enabled', + }, + author: { type: 'string', description: 'The author of the cited source' }, + publishedDate: { type: 'string', description: 'Publication date of the cited source' }, }, }, }, + requestId: { type: 'string', description: 'Exa request identifier, useful for support' }, }, } diff --git a/apps/sim/tools/exa/exa.test.ts b/apps/sim/tools/exa/exa.test.ts new file mode 100644 index 00000000000..cd22af58ee3 --- /dev/null +++ b/apps/sim/tools/exa/exa.test.ts @@ -0,0 +1,302 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { ExaBlock } from '@/blocks/blocks/exa' +import { agentTool } from '@/tools/exa/agent' +import { answerTool } from '@/tools/exa/answer' +import { findSimilarLinksTool } from '@/tools/exa/find_similar_links' +import { getContentsTool } from '@/tools/exa/get_contents' +import { searchTool } from '@/tools/exa/search' +import { applyFreshness, resolveCategory } from '@/tools/exa/utils' + +const API_KEY = 'test-key' + +function searchBody(params: Record) { + return searchTool.request.body?.({ query: 'q', apiKey: API_KEY, ...params } as never) as Record< + string, + any + > +} + +describe('applyFreshness', () => { + it('sends maxAgeHours alone, since Exa 400s when both controls are present', () => { + const target: Record = {} + applyFreshness(target, { maxAgeHours: 24, livecrawl: 'never' }) + expect(target).toEqual({ maxAgeHours: 24 }) + }) + + it('treats maxAgeHours of 0 as a real value rather than falsy', () => { + const target: Record = {} + applyFreshness(target, { maxAgeHours: 0, livecrawl: 'always' }) + expect(target).toEqual({ maxAgeHours: 0 }) + }) + + it('keeps -1 (cache only) distinct from an unset value', () => { + const target: Record = {} + applyFreshness(target, { maxAgeHours: -1 }) + expect(target).toEqual({ maxAgeHours: -1 }) + }) + + it('falls back to deprecated livecrawl when no maxAgeHours is set', () => { + const target: Record = {} + applyFreshness(target, { livecrawl: 'always' }) + expect(target).toEqual({ livecrawl: 'always' }) + }) + + it('sends neither control when the user configured neither', () => { + const target: Record = {} + applyFreshness(target, {}) + expect(target).toEqual({}) + }) +}) + +describe('resolveCategory', () => { + it('remaps retired categories onto their current equivalents', () => { + expect(resolveCategory('research_paper')).toBe('publication') + expect(resolveCategory('news_article')).toBe('news') + expect(resolveCategory('personal_site')).toBe('personal site') + expect(resolveCategory('linkedin profile')).toBe('people') + }) + + it('passes current categories through untouched', () => { + expect(resolveCategory('company')).toBe('company') + expect(resolveCategory('financial report')).toBe('financial report') + }) + + it('passes through categories with no modern equivalent', () => { + expect(resolveCategory('github')).toBe('github') + }) + + it('omits an unset category', () => { + expect(resolveCategory(undefined)).toBeUndefined() + expect(resolveCategory('')).toBeUndefined() + }) +}) + +describe('exa_search request body', () => { + it('nests content options under contents, which /search requires', () => { + const body = searchBody({ text: true, highlights: true, summary: true }) + expect(body.contents).toEqual({ text: true, highlights: true, summary: true }) + expect(body.text).toBeUndefined() + }) + + it('preserves an object-form text param so per-caller character caps survive', () => { + const body = searchBody({ text: { maxCharacters: 500 } }) + expect(body.contents.text).toEqual({ maxCharacters: 500 }) + }) + + it('prefers a summary query over the plain summary toggle', () => { + const body = searchBody({ summary: true, summaryQuery: 'what do they sell' }) + expect(body.contents.summary).toEqual({ query: 'what do they sell' }) + }) + + it('never sends livecrawl alongside maxAgeHours', () => { + const body = searchBody({ livecrawl: 'never', maxAgeHours: 24 }) + expect(body.contents.maxAgeHours).toBe(24) + expect(body.contents.livecrawl).toBeUndefined() + }) + + it('remaps a legacy category saved by an older workflow', () => { + expect(searchBody({ category: 'research_paper' }).category).toBe('publication') + }) + + it('still accepts legacy search types saved by older workflows', () => { + expect(searchBody({ type: 'neural' }).type).toBe('neural') + }) + + it('splits comma-separated domain filters into arrays', () => { + const body = searchBody({ includeDomains: 'a.com, b.com ,, c.com' }) + expect(body.includeDomains).toEqual(['a.com', 'b.com', 'c.com']) + }) + + it('parses a stringified outputSchema from the JSON editor', () => { + const body = searchBody({ outputSchema: '{"type":"object"}' }) + expect(body.outputSchema).toEqual({ type: 'object' }) + }) + + it('rejects a malformed outputSchema instead of sending it', () => { + expect(() => searchBody({ outputSchema: '{not json' })).toThrow(/Invalid outputSchema/) + }) + + it('omits contents entirely when no content options were set', () => { + expect(searchBody({}).contents).toBeUndefined() + }) +}) + +describe('exa_get_contents request body', () => { + const body = (params: Record) => + getContentsTool.request.body?.({ apiKey: API_KEY, ...params } as never) as Record + + it('places content options at the top level, unlike /search', () => { + const result = body({ urls: 'https://a.com', text: true, highlights: true }) + expect(result.text).toBe(true) + expect(result.highlights).toBe(true) + expect(result.contents).toBeUndefined() + }) + + it('accepts ids as an alternative selector to urls', () => { + const result = body({ ids: 'id-1, id-2' }) + expect(result.ids).toEqual(['id-1', 'id-2']) + expect(result.urls).toBeUndefined() + }) + + it('rejects both selectors, which Exa 400s on', () => { + expect(() => body({ urls: 'https://a.com', ids: 'id-1' })).toThrow(/not both/) + }) + + it('rejects neither selector', () => { + expect(() => body({})).toThrow(/requires either urls or ids/) + }) +}) + +describe('exa_answer', () => { + it('describes text as controlling source text, not the answer', () => { + expect(answerTool.params.text.description).toMatch(/cited source/i) + }) + + it('parses a stringified outputSchema', () => { + const body = answerTool.request.body?.({ + query: 'q', + apiKey: API_KEY, + outputSchema: '{"type":"object"}', + } as never) as Record + expect(body.outputSchema).toEqual({ type: 'object' }) + }) +}) + +describe('exa block', () => { + it('routes the retired research operation to the agent tool', () => { + expect(ExaBlock.tools.config?.tool?.({ operation: 'exa_research' })).toBe('exa_agent') + }) + + it('carries a saved research model over to an agent effort level', () => { + const params = ExaBlock.tools.config?.params?.({ + operation: 'exa_research', + model: 'exa-research-pro', + }) as Record + expect(params.effort).toBe('high') + }) + + it('keeps the old standard research depth when no model was stored', () => { + const params = ExaBlock.tools.config?.params?.({ + operation: 'exa_research', + }) as Record + expect(params.effort).toBe('medium') + }) + + it('leaves both Get Contents selectors optional so the ids-only path stays valid', () => { + for (const id of ['urls', 'ids']) { + const selector = ExaBlock.subBlocks.find( + (block) => block.id === id && block.condition?.value === 'exa_get_contents' + ) + expect(selector?.required).toBeUndefined() + } + }) + + it('keeps the legacy model sub-block so the serializer preserves its value', () => { + const model = ExaBlock.subBlocks.find((block) => block.id === 'model') + expect(model?.condition?.value).toBe('exa_research') + }) + + it('does not map a model value on non-research operations', () => { + const params = ExaBlock.tools.config?.params?.({ + operation: 'exa_agent', + model: 'exa-research-pro', + }) as Record + expect(params.effort).toBeUndefined() + }) + + it('coerces maxAgeHours of 0 rather than dropping it as falsy', () => { + const params = ExaBlock.tools.config?.params?.({ + operation: 'exa_search', + maxAgeHours: '0', + }) as Record + expect(params.maxAgeHours).toBe(0) + }) + + it('offers only categories Exa currently supports', () => { + const category = ExaBlock.subBlocks.find( + (block) => block.id === 'category' && block.condition?.value === 'exa_search' + ) + expect(category?.options).toEqual([ + { label: 'None', id: '' }, + { label: 'Company', id: 'company' }, + { label: 'Publication', id: 'publication' }, + { label: 'News', id: 'news' }, + { label: 'Personal Site', id: 'personal site' }, + { label: 'Financial Report', id: 'financial report' }, + { label: 'People', id: 'people' }, + ]) + }) + + it('no longer defaults live crawling to never, which suppressed fresh content', () => { + expect(ExaBlock.subBlocks.some((block) => block.id === 'livecrawl')).toBe(false) + }) + + it('exposes every operation it advertises', () => { + const operations = ExaBlock.subBlocks.find((block) => block.id === 'operation') + const advertised = operations?.options as { id: string }[] + expect(advertised.map((option) => option.id).sort()).toEqual([...ExaBlock.tools.access!].sort()) + }) +}) + +describe('exa_agent terminal statuses', () => { + const settle = (status: string, stopReason: string | null = null) => + agentTool.postProcess?.( + { + success: true, + output: { runId: 'agent_run_1', status, stopReason, text: '', structured: { a: 1 } }, + } as never, + { apiKey: API_KEY, query: 'q' } as never, + {} as never + ) + + it('reports a run that is already failed on creation as a failure', async () => { + const result = await settle('failed', 'error') + expect(result?.success).toBe(false) + expect(result?.error).toMatch(/failed: error/) + }) + + it('reports a cancelled run as a failure', async () => { + const result = await settle('cancelled') + expect(result?.success).toBe(false) + expect(result?.error).toMatch(/cancelled/) + }) + + it('emits the retired research output shape so saved references still resolve', async () => { + const result = await agentTool.postProcess?.( + { + success: true, + output: { runId: 'agent_run_1', status: 'completed', text: 'the answer' }, + } as never, + { apiKey: API_KEY, query: 'q' } as never, + {} as never + ) + expect(result?.output.research).toEqual([ + { title: 'Research Complete', url: '', summary: 'the answer', text: 'the answer', score: 1 }, + ]) + }) + + it('falls back to the structured payload when a completed run has no text', async () => { + const result = await settle('completed') + expect(result?.success).toBe(true) + expect(result?.output.text).toBe(JSON.stringify({ a: 1 }, null, 2)) + }) + + it('fails when the create call returns no run ID', async () => { + const result = await agentTool.postProcess?.( + { success: true, output: { text: '' } } as never, + { apiKey: API_KEY, query: 'q' } as never, + {} as never + ) + expect(result?.success).toBe(false) + expect(result?.error).toMatch(/run ID/) + }) +}) + +describe('find similar links', () => { + it('is marked deprecated so new workflows prefer search', () => { + expect(findSimilarLinksTool.description).toMatch(/deprecated/i) + }) +}) diff --git a/apps/sim/tools/exa/find_similar_links.ts b/apps/sim/tools/exa/find_similar_links.ts index 1685e601168..b11aa5e3320 100644 --- a/apps/sim/tools/exa/find_similar_links.ts +++ b/apps/sim/tools/exa/find_similar_links.ts @@ -1,6 +1,17 @@ import type { ExaFindSimilarLinksParams, ExaFindSimilarLinksResponse } from '@/tools/exa/types' +import { + applyFreshness, + parseCommaList, + requireCostTotal, + resolveCategory, +} from '@/tools/exa/utils' import type { ToolConfig } from '@/tools/types' +/** + * Exa has deprecated `/findSimilar` in favor of running a `/search` with a query + * derived from the seed page. The endpoint still serves traffic, so this tool + * remains available for workflows already built on it. + */ export const findSimilarLinksTool: ToolConfig< ExaFindSimilarLinksParams, ExaFindSimilarLinksResponse @@ -8,8 +19,8 @@ export const findSimilarLinksTool: ToolConfig< id: 'exa_find_similar_links', name: 'Exa Find Similar Links', description: - 'Find webpages similar to a given URL using Exa AI. Returns a list of similar links with titles and text snippets.', - version: '1.0.0', + 'Find webpages similar to a given URL using Exa AI. Deprecated by Exa in favor of Search — prefer Search for new workflows.', + version: '2.0.0', params: { url: { @@ -22,7 +33,7 @@ export const findSimilarLinksTool: ToolConfig< type: 'number', required: false, visibility: 'user-or-llm', - description: 'Number of similar links to return (e.g., 5, 10, 25). Default: 10, max: 25', + description: 'Number of similar links to return (1-100). Default: 10', }, text: { type: 'boolean', @@ -50,6 +61,13 @@ export const findSimilarLinksTool: ToolConfig< visibility: 'user-only', description: 'Exclude the source domain from results (default: false)', }, + category: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'Filter by category: company, publication, news, personal site, financial report, people', + }, highlights: { type: 'boolean', required: false, @@ -62,12 +80,25 @@ export const findSimilarLinksTool: ToolConfig< visibility: 'user-only', description: 'Include AI-generated summaries in results (default: false)', }, + maxAgeHours: { + type: 'number', + required: false, + visibility: 'user-only', + description: + 'Cache freshness in hours (-1 to 720). 0 always crawls live, -1 uses cache only. Cannot be combined with livecrawl.', + }, + livecrawlTimeout: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Live crawl timeout in milliseconds (max 90000). Default: 10000', + }, livecrawl: { type: 'string', required: false, visibility: 'user-only', description: - 'Live crawling mode: never (default), fallback, always, or preferred (always try livecrawl, fall back to cache if fails)', + 'Deprecated: use maxAgeHours instead. Live crawling mode: never, fallback, always, or preferred', }, apiKey: { type: 'string', @@ -83,11 +114,8 @@ export const findSimilarLinksTool: ToolConfig< pricing: { type: 'custom', getCost: (_params, output) => { - const costDollars = output.__costDollars as { total?: number } | undefined - if (costDollars?.total == null) { - throw new Error('Exa find_similar_links response missing costDollars field') - } - return { cost: costDollars.total, metadata: { costDollars } } + const cost = requireCostTotal(output, 'find_similar_links') + return { cost, metadata: { costDollars: output.__costDollars } } }, }, rateLimit: { @@ -108,34 +136,25 @@ export const findSimilarLinksTool: ToolConfig< url: params.url, } - // Add optional parameters if provided if (params.numResults) body.numResults = Number(params.numResults) - // Domain filtering - if (params.includeDomains) { - body.includeDomains = params.includeDomains - .split(',') - .map((d: string) => d.trim()) - .filter((d: string) => d.length > 0) - } - if (params.excludeDomains) { - body.excludeDomains = params.excludeDomains - .split(',') - .map((d: string) => d.trim()) - .filter((d: string) => d.length > 0) - } + const includeDomains = parseCommaList(params.includeDomains) + if (includeDomains) body.includeDomains = includeDomains + const excludeDomains = parseCommaList(params.excludeDomains) + if (excludeDomains) body.excludeDomains = excludeDomains if (params.excludeSourceDomain !== undefined) { body.excludeSourceDomain = params.excludeSourceDomain } - // Content options - build contents object + const category = resolveCategory(params.category) + if (category) body.category = category + const contents: Record = {} if (params.text !== undefined) contents.text = params.text if (params.highlights !== undefined) contents.highlights = params.highlights if (params.summary !== undefined) contents.summary = params.summary - // Live crawl mode should be inside contents - if (params.livecrawl) contents.livecrawl = params.livecrawl + applyFreshness(contents, params) if (Object.keys(contents).length > 0) { body.contents = contents @@ -151,14 +170,16 @@ export const findSimilarLinksTool: ToolConfig< return { success: true, output: { - similarLinks: data.results.map((result: any) => ({ + similarLinks: (data.results ?? []).map((result: any) => ({ + id: result.id, title: result.title || '', url: result.url, text: result.text || '', summary: result.summary, highlights: result.highlights, - score: result.score || 0, + score: result.score, })), + requestId: data.requestId, __costDollars: data.costDollars, }, } @@ -171,12 +192,19 @@ export const findSimilarLinksTool: ToolConfig< items: { type: 'object', properties: { + id: { type: 'string', description: 'Exa identifier for the similar page' }, title: { type: 'string', description: 'The title of the similar webpage' }, url: { type: 'string', description: 'The URL of the similar webpage' }, text: { type: 'string', description: 'Text snippet or full content from the similar webpage', }, + summary: { type: 'string', description: 'AI-generated summary of the similar webpage' }, + highlights: { + type: 'array', + description: 'Relevant snippets extracted from the page', + items: { type: 'string' }, + }, score: { type: 'number', description: 'Similarity score indicating how similar the page is', @@ -184,5 +212,6 @@ export const findSimilarLinksTool: ToolConfig< }, }, }, + requestId: { type: 'string', description: 'Exa request identifier, useful for support' }, }, } diff --git a/apps/sim/tools/exa/get_contents.ts b/apps/sim/tools/exa/get_contents.ts index c1b96967bd1..37c5245fdb4 100644 --- a/apps/sim/tools/exa/get_contents.ts +++ b/apps/sim/tools/exa/get_contents.ts @@ -1,4 +1,5 @@ import type { ExaGetContentsParams, ExaGetContentsResponse } from '@/tools/exa/types' +import { applyFreshness, buildExtras, parseCommaList, requireCostTotal } from '@/tools/exa/utils' import type { ToolConfig } from '@/tools/types' export const getContentsTool: ToolConfig = { @@ -6,14 +7,22 @@ export const getContentsTool: ToolConfig { - const costDollars = output.__costDollars as { total?: number } | undefined - if (costDollars?.total == null) { - throw new Error('Exa get_contents response missing costDollars field') - } - return { cost: costDollars.total, metadata: { costDollars } } + const cost = requireCostTotal(output, 'get_contents') + return { cost, metadata: { costDollars: output.__costDollars } } }, }, rateLimit: { @@ -89,50 +126,37 @@ export const getContentsTool: ToolConfig { - // Parse the comma-separated URLs into an array - const urlsString = params.urls - const urlArray = urlsString - .split(',') - .map((url: string) => url.trim()) - .filter((url: string) => url.length > 0) + const urls = parseCommaList(params.urls) + const ids = parseCommaList(params.ids) - const body: Record = { - urls: urlArray, + /** Exa rejects a request carrying both selectors with a 400. */ + if (urls && ids) { + throw new Error('Provide either urls or ids for Exa Get Contents, not both') } - - // Add optional parameters if provided - if (params.text !== undefined) { - body.text = params.text + if (!urls && !ids) { + throw new Error('Exa Get Contents requires either urls or ids') } - // Add summary with query if provided + const body: Record = urls ? { urls } : { ids } + + if (params.text !== undefined) body.text = params.text + if (params.summaryQuery) { - body.summary = { - query: params.summaryQuery, - } + body.summary = { query: params.summaryQuery } + } else if (params.summary !== undefined) { + body.summary = params.summary } - // Subpages crawling - if (params.subpages !== undefined) { - body.subpages = Number(params.subpages) - } + if (params.subpages !== undefined) body.subpages = Number(params.subpages) + const subpageTarget = parseCommaList(params.subpageTarget) + if (subpageTarget) body.subpageTarget = subpageTarget - if (params.subpageTarget) { - body.subpageTarget = params.subpageTarget - .split(',') - .map((target: string) => target.trim()) - .filter((target: string) => target.length > 0) - } + if (params.highlights !== undefined) body.highlights = params.highlights - // Content options - if (params.highlights !== undefined) { - body.highlights = params.highlights - } + const extras = buildExtras(params) + if (extras) body.extras = extras - // Live crawl mode - if (params.livecrawl) { - body.livecrawl = params.livecrawl - } + applyFreshness(body, params) return body }, @@ -144,13 +168,20 @@ export const getContentsTool: ToolConfig ({ + results: (data.results ?? []).map((result: any) => ({ + id: result.id, url: result.url, title: result.title || '', text: result.text || '', summary: result.summary || '', highlights: result.highlights, + highlightScores: result.highlightScores, + subpages: result.subpages, + entities: result.entities, + extras: result.extras, })), + statuses: data.statuses, + requestId: data.requestId, __costDollars: data.costDollars, }, } @@ -163,12 +194,35 @@ export const getContentsTool: ToolConfig = { - id: 'exa_research', - name: 'Exa Research', - description: - 'Perform comprehensive research using AI to generate detailed reports with citations', - version: '1.0.0', - params: { - query: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: 'Research query or topic', - }, - model: { - type: 'string', - required: false, - visibility: 'user-only', - description: 'Research model: exa-research-fast, exa-research (default), or exa-research-pro', - }, - apiKey: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'Exa AI API Key', - }, - }, - - request: { - url: 'https://api.exa.ai/research/v1', - method: 'POST', - headers: (params) => ({ - 'Content-Type': 'application/json', - 'x-api-key': params.apiKey, - }), - body: (params) => { - const body: any = { - instructions: params.query, - } - - // Add model if specified, otherwise use default - if (params.model) { - body.model = params.model - } - - return body - }, - }, - - transformResponse: async (response: Response) => { - const data = await response.json() - - return { - success: true, - output: { - taskId: data.researchId, - research: [], - }, - } - }, - postProcess: async (result, params) => { - if (!result.success) { - return result - } - - const taskId = result.output.taskId - logger.info(`Exa research task ${taskId} created, polling for completion...`) - - let elapsedTime = 0 - - while (elapsedTime < MAX_POLL_TIME_MS) { - try { - const statusResponse = await fetch(`https://api.exa.ai/research/v1/${taskId}`, { - method: 'GET', - headers: { - 'x-api-key': params.apiKey, - 'Content-Type': 'application/json', - }, - }) - - if (!statusResponse.ok) { - throw new Error(`Failed to get task status: ${statusResponse.statusText}`) - } - - const taskData = await statusResponse.json() - logger.info(`Exa research task ${taskId} status: ${taskData.status}`) - - if (taskData.status === 'completed') { - // The completed response contains output.content (text) and output.parsed (structured data) - const content = - taskData.output?.content || taskData.output?.parsed || 'Research completed successfully' - - result.output = { - research: [ - { - title: 'Research Complete', - url: '', - summary: typeof content === 'string' ? content : JSON.stringify(content, null, 2), - text: typeof content === 'string' ? content : JSON.stringify(content, null, 2), - publishedDate: undefined, - author: undefined, - score: 1.0, - }, - ], - } - return result - } - - if (taskData.status === 'failed' || taskData.status === 'canceled') { - return { - ...result, - success: false, - error: `Research task ${taskData.status}: ${taskData.error || 'Unknown error'}`, - } - } - - await sleep(POLL_INTERVAL_MS) - elapsedTime += POLL_INTERVAL_MS - } catch (error: any) { - logger.error('Error polling for research task status:', { - message: error.message || 'Unknown error', - taskId, - }) - - return { - ...result, - success: false, - error: `Error polling for research task status: ${error.message || 'Unknown error'}`, - } - } - } - - logger.warn( - `Research task ${taskId} did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)` - ) - return { - ...result, - success: false, - error: `Research task did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)`, - } - }, - - outputs: { - research: { - type: 'array', - description: 'Comprehensive research findings with citations and summaries', - items: { - type: 'object', - properties: { - title: { type: 'string' }, - url: { type: 'string' }, - summary: { type: 'string' }, - text: { type: 'string' }, - publishedDate: { type: 'string' }, - author: { type: 'string' }, - score: { type: 'number' }, - }, - }, - }, - }, -} diff --git a/apps/sim/tools/exa/search.ts b/apps/sim/tools/exa/search.ts index c3b2c5c779d..46606450c97 100644 --- a/apps/sim/tools/exa/search.ts +++ b/apps/sim/tools/exa/search.ts @@ -1,4 +1,12 @@ import type { ExaSearchParams, ExaSearchResponse } from '@/tools/exa/types' +import { + applyFreshness, + buildExtras, + parseCommaList, + parseJsonSchema, + requireCostTotal, + resolveCategory, +} from '@/tools/exa/utils' import type { ToolConfig } from '@/tools/types' export const searchTool: ToolConfig = { @@ -6,7 +14,7 @@ export const searchTool: ToolConfig = { name: 'Exa Search', description: 'Search the web using Exa AI. Returns relevant search results with titles, URLs, and text snippets.', - version: '1.0.0', + version: '2.0.0', params: { query: { @@ -19,19 +27,14 @@ export const searchTool: ToolConfig = { type: 'number', required: false, visibility: 'user-or-llm', - description: 'Number of results to return (e.g., 5, 10, 25). Default: 10, max: 25', - }, - useAutoprompt: { - type: 'boolean', - required: false, - visibility: 'user-or-llm', - description: 'Whether to use autoprompt to improve the query (true or false). Default: false', + description: 'Number of results to return (1-100). Default: 10', }, type: { type: 'string', required: false, visibility: 'user-or-llm', - description: 'Search type: "neural", "keyword", "auto", or "fast". Default: "auto"', + description: + 'Search type: "instant", "fast", "auto", "deep-lite", "deep", or "deep-reasoning". Default: "auto"', }, includeDomains: { type: 'string', @@ -52,7 +55,7 @@ export const searchTool: ToolConfig = { required: false, visibility: 'user-only', description: - 'Filter by category: company, research paper, news, pdf, github, tweet, personal site, linkedin profile, financial report', + 'Filter by category: company, publication, news, personal site, financial report, people', }, text: { type: 'boolean', @@ -72,31 +75,82 @@ export const searchTool: ToolConfig = { visibility: 'user-only', description: 'Include AI-generated summaries in results (default: false)', }, - livecrawl: { + summaryQuery: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Query to focus the generated summaries on a specific question', + }, + subpages: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Number of subpages to crawl per result (0-100). Default: 0', + }, + subpageTarget: { type: 'string', required: false, visibility: 'user-only', description: - 'Live crawling mode: never (default), fallback, always, or preferred (always try livecrawl, fall back to cache if fails)', + 'Comma-separated keywords to target specific subpages (e.g., "docs,pricing,about")', }, - startCrawlDate: { - type: 'string', + extrasLinks: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Number of links to extract from each result page (0-1000). Default: 0', + }, + extrasImageLinks: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Number of image URLs to extract from each result page (0-1000). Default: 0', + }, + outputSchema: { + type: 'json', required: false, visibility: 'user-or-llm', description: - 'Only include results crawled on or after this ISO 8601 date (e.g., "2024-01-01" or "2024-01-01T00:00:00.000Z")', + 'JSON Schema describing a synthesized answer to build from the results. Returned in structuredOutput.', }, - endCrawlDate: { + systemPrompt: { type: 'string', required: false, visibility: 'user-or-llm', - description: 'Only include results crawled on or before this ISO 8601 date', + description: 'Additional guidance for generating the synthesized output', + }, + userLocation: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Two-letter ISO country code to localize results (e.g., "US")', + }, + maxAgeHours: { + type: 'number', + required: false, + visibility: 'user-only', + description: + 'Cache freshness in hours (-1 to 720). 0 always crawls live, -1 uses cache only. Cannot be combined with livecrawl.', + }, + livecrawlTimeout: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Live crawl timeout in milliseconds (max 90000). Default: 10000', + }, + livecrawl: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'Deprecated: use maxAgeHours instead. Live crawling mode: never, fallback, always, or preferred', }, startPublishedDate: { type: 'string', required: false, visibility: 'user-or-llm', - description: 'Only include results published on or after this ISO 8601 date', + description: + 'Only include results published on or after this ISO 8601 date (e.g., "2024-01-01" or "2024-01-01T00:00:00.000Z")', }, endPublishedDate: { type: 'string', @@ -104,6 +158,20 @@ export const searchTool: ToolConfig = { visibility: 'user-or-llm', description: 'Only include results published on or before this ISO 8601 date', }, + startCrawlDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Deprecated: use startPublishedDate. Only include results crawled on or after this ISO 8601 date', + }, + endCrawlDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Deprecated: use endPublishedDate. Only include results crawled on or before this ISO 8601 date', + }, apiKey: { type: 'string', required: true, @@ -118,11 +186,8 @@ export const searchTool: ToolConfig = { pricing: { type: 'custom', getCost: (_params, output) => { - const costDollars = output.__costDollars as { total?: number } | undefined - if (costDollars?.total == null) { - throw new Error('Exa search response missing costDollars field') - } - return { cost: costDollars.total, metadata: { costDollars } } + const cost = requireCostTotal(output, 'search') + return { cost, metadata: { costDollars: output.__costDollars } } }, }, rateLimit: { @@ -143,54 +208,51 @@ export const searchTool: ToolConfig = { query: params.query, } - // Add optional parameters if provided if (params.numResults) body.numResults = Number(params.numResults) - if (params.useAutoprompt !== undefined) body.useAutoprompt = params.useAutoprompt if (params.type) body.type = params.type + if (params.userLocation) body.userLocation = params.userLocation - // Domain filtering - if (params.includeDomains) { - body.includeDomains = params.includeDomains - .split(',') - .map((d: string) => d.trim()) - .filter((d: string) => d.length > 0) - } - if (params.excludeDomains) { - body.excludeDomains = params.excludeDomains - .split(',') - .map((d: string) => d.trim()) - .filter((d: string) => d.length > 0) - } + const includeDomains = parseCommaList(params.includeDomains) + if (includeDomains) body.includeDomains = includeDomains + const excludeDomains = parseCommaList(params.excludeDomains) + if (excludeDomains) body.excludeDomains = excludeDomains - // Category filtering - if (params.category) body.category = params.category + const category = resolveCategory(params.category) + if (category) body.category = category - // Date filtering - if (params.startCrawlDate) body.startCrawlDate = params.startCrawlDate - if (params.endCrawlDate) body.endCrawlDate = params.endCrawlDate if (params.startPublishedDate) body.startPublishedDate = params.startPublishedDate if (params.endPublishedDate) body.endPublishedDate = params.endPublishedDate + if (params.startCrawlDate) body.startCrawlDate = params.startCrawlDate + if (params.endCrawlDate) body.endCrawlDate = params.endCrawlDate - // Build contents object for content options + const outputSchema = parseJsonSchema(params.outputSchema, 'outputSchema') + if (outputSchema) body.outputSchema = outputSchema + if (params.systemPrompt) body.systemPrompt = params.systemPrompt + + /** + * On `/search` the content options are nested under `contents` — unlike + * `/contents`, where the same fields sit at the top level. + */ const contents: Record = {} - if (params.text !== undefined) { - contents.text = params.text - } + if (params.text !== undefined) contents.text = params.text + if (params.highlights !== undefined) contents.highlights = params.highlights - if (params.highlights !== undefined) { - contents.highlights = params.highlights - } - - if (params.summary !== undefined) { + if (params.summaryQuery) { + contents.summary = { query: params.summaryQuery } + } else if (params.summary !== undefined) { contents.summary = params.summary } - if (params.livecrawl) { - contents.livecrawl = params.livecrawl - } + if (params.subpages) contents.subpages = Number(params.subpages) + const subpageTarget = parseCommaList(params.subpageTarget) + if (subpageTarget) contents.subpageTarget = subpageTarget + + const extras = buildExtras(params) + if (extras) contents.extras = extras + + applyFreshness(contents, params) - // Add contents to body if not empty if (Object.keys(contents).length > 0) { body.contents = contents } @@ -205,7 +267,8 @@ export const searchTool: ToolConfig = { return { success: true, output: { - results: data.results.map((result: any) => ({ + results: (data.results ?? []).map((result: any) => ({ + id: result.id, title: result.title || '', url: result.url, publishedDate: result.publishedDate, @@ -215,8 +278,15 @@ export const searchTool: ToolConfig = { image: result.image, text: result.text, highlights: result.highlights, + highlightScores: result.highlightScores, + subpages: result.subpages, + entities: result.entities, + extras: result.extras, score: result.score, })), + requestId: data.requestId, + structuredOutput: data.output?.content, + grounding: data.output?.grounding, __costDollars: data.costDollars, }, } @@ -229,6 +299,10 @@ export const searchTool: ToolConfig = { items: { type: 'object', properties: { + id: { + type: 'string', + description: 'Result identifier, usable as an id on the Get Contents operation', + }, title: { type: 'string', description: 'The title of the search result' }, url: { type: 'string', description: 'The URL of the search result' }, publishedDate: { type: 'string', description: 'Date when the content was published' }, @@ -237,9 +311,43 @@ export const searchTool: ToolConfig = { favicon: { type: 'string', description: "URL of the site's favicon" }, image: { type: 'string', description: 'URL of a representative image from the page' }, text: { type: 'string', description: 'Text snippet or full content from the page' }, - score: { type: 'number', description: 'Relevance score for the search result' }, + highlights: { + type: 'array', + description: 'Relevant snippets extracted from the page', + items: { type: 'string' }, + }, + highlightScores: { + type: 'array', + description: 'Similarity score for each highlight', + items: { type: 'number' }, + }, + subpages: { type: 'json', description: 'Crawled subpages of the result' }, + entities: { + type: 'json', + description: 'Structured entity data for company, people, and publication results', + }, + extras: { + type: 'json', + description: 'Extracted links and image links when requested', + }, + score: { + type: 'number', + description: 'Relevance score. Only returned by the legacy neural search type', + optional: true, + }, }, }, }, + requestId: { type: 'string', description: 'Exa request identifier, useful for support' }, + structuredOutput: { + type: 'json', + description: 'Synthesized answer matching outputSchema, when one was supplied', + optional: true, + }, + grounding: { + type: 'json', + description: 'Field-level citations backing the synthesized output', + optional: true, + }, }, } diff --git a/apps/sim/tools/exa/types.ts b/apps/sim/tools/exa/types.ts index 3a74f56f89f..7f7e79c4cd7 100644 --- a/apps/sim/tools/exa/types.ts +++ b/apps/sim/tools/exa/types.ts @@ -1,50 +1,92 @@ -// Common types for Exa AI tools import type { ToolResponse } from '@/tools/types' -// Common parameters for all Exa AI tools interface ExaBaseParams { apiKey: string } -/** Cost breakdown returned by Exa API responses */ +/** Cost breakdown returned by Exa API responses. */ interface ExaCostDollars { total: number } -// Search tool types -export interface ExaSearchParams extends ExaBaseParams { +/** + * Exa's content-freshness controls. `maxAgeHours` (-1 cache-only, 0 always live + * crawl, 1-720 cache-if-younger-than) replaced `livecrawl`, which is deprecated + * but still accepted. Sending both is a 400 — see `applyFreshness`. + */ +export interface ExaFreshnessParams { + maxAgeHours?: number + livecrawlTimeout?: number + /** @deprecated Superseded by `maxAgeHours`; retained for saved workflows. */ + livecrawl?: 'always' | 'fallback' | 'never' | 'preferred' +} + +/** + * Search modes Exa accepts. `instant` through `deep-reasoning` are the current + * documented set; `neural`, `keyword`, and `hybrid` are legacy values the API + * still honors, kept so workflows saved against the old dropdown keep running. + */ +export type ExaSearchType = + | 'instant' + | 'fast' + | 'auto' + | 'deep-lite' + | 'deep' + | 'deep-reasoning' + | 'neural' + | 'keyword' + | 'hybrid' + +/** Field-level citations Exa returns alongside structured output. */ +interface ExaGrounding { + field: string + citations: { url: string; title?: string }[] + confidence?: number +} + +interface ExaEntity { + id: string + type: string + version: number + properties: Record +} + +interface ExaSubpage { + title?: string + url: string + publishedDate?: string + author?: string + id?: string +} + +export interface ExaSearchParams extends ExaBaseParams, ExaFreshnessParams { query: string numResults?: number - useAutoprompt?: boolean - type?: 'auto' | 'neural' | 'keyword' | 'fast' - // Domain filtering + type?: ExaSearchType includeDomains?: string excludeDomains?: string - // Category filtering - category?: - | 'company' - | 'research_paper' - | 'news_article' - | 'pdf' - | 'github' - | 'tweet' - | 'movie' - | 'song' - | 'personal_site' - // Content options - text?: boolean | { maxCharacters?: number } - highlights?: boolean | { query?: string; numSentences?: number; highlightsPerUrl?: number } - summary?: boolean | { query?: string } - // Live crawl mode - livecrawl?: 'always' | 'fallback' | 'never' - // Date filters (ISO 8601) - startCrawlDate?: string - endCrawlDate?: string + category?: string + text?: boolean + highlights?: boolean + summary?: boolean + summaryQuery?: string + subpages?: number + subpageTarget?: string + extrasLinks?: number + extrasImageLinks?: number + outputSchema?: string | Record + systemPrompt?: string + userLocation?: string startPublishedDate?: string endPublishedDate?: string + /** @deprecated Crawl-date filters are deprecated; use the published-date pair. */ + startCrawlDate?: string + /** @deprecated Crawl-date filters are deprecated; use the published-date pair. */ + endCrawlDate?: string } interface ExaSearchResult { + id?: string title: string url: string publishedDate?: string @@ -54,124 +96,150 @@ interface ExaSearchResult { image?: string text?: string highlights?: string[] - score: number + highlightScores?: number[] + subpages?: ExaSubpage[] + entities?: ExaEntity[] + extras?: Record + /** Only returned by the legacy `neural` search type. */ + score?: number } export interface ExaSearchResponse extends ToolResponse { output: { results: ExaSearchResult[] + requestId?: string + structuredOutput?: unknown + grounding?: ExaGrounding[] __costDollars?: ExaCostDollars } } -// Get Contents tool types -export interface ExaGetContentsParams extends ExaBaseParams { - urls: string - text?: boolean | { maxCharacters?: number } +export interface ExaGetContentsParams extends ExaBaseParams, ExaFreshnessParams { + urls?: string + /** Result IDs from a prior search; mutually exclusive with `urls`. */ + ids?: string + text?: boolean + summary?: boolean summaryQuery?: string - // Subpages crawling subpages?: number subpageTarget?: string - // Content options - highlights?: boolean | { query?: string; numSentences?: number; highlightsPerUrl?: number } - // Live crawl mode - livecrawl?: 'always' | 'fallback' | 'never' + highlights?: boolean + extrasLinks?: number + extrasImageLinks?: number } interface ExaGetContentsResult { + id?: string url: string title: string text?: string summary?: string highlights?: string[] + highlightScores?: number[] + subpages?: ExaSubpage[] + entities?: ExaEntity[] + extras?: Record +} + +/** Per-URL crawl outcome, so partial failures are visible to the caller. */ +interface ExaContentsStatus { + id: string + status: 'success' | 'error' + source?: 'cached' | 'crawled' + error?: Record } export interface ExaGetContentsResponse extends ToolResponse { output: { results: ExaGetContentsResult[] + statuses?: ExaContentsStatus[] + requestId?: string __costDollars?: ExaCostDollars } } -// Find Similar Links tool types -export interface ExaFindSimilarLinksParams extends ExaBaseParams { +export interface ExaFindSimilarLinksParams extends ExaBaseParams, ExaFreshnessParams { url: string numResults?: number - text?: boolean | { maxCharacters?: number } - // Domain filtering + text?: boolean includeDomains?: string excludeDomains?: string excludeSourceDomain?: boolean - // Category filtering - category?: - | 'company' - | 'research_paper' - | 'news_article' - | 'pdf' - | 'github' - | 'tweet' - | 'movie' - | 'song' - | 'personal_site' - // Content options - highlights?: boolean | { query?: string; numSentences?: number; highlightsPerUrl?: number } - summary?: boolean | { query?: string } - // Live crawl mode - livecrawl?: 'always' | 'fallback' | 'never' + category?: string + highlights?: boolean + summary?: boolean } interface ExaSimilarLink { + id?: string title: string url: string text?: string summary?: string highlights?: string[] - score: number + score?: number } export interface ExaFindSimilarLinksResponse extends ToolResponse { output: { similarLinks: ExaSimilarLink[] + requestId?: string __costDollars?: ExaCostDollars } } -// Answer tool types export interface ExaAnswerParams extends ExaBaseParams { query: string + /** Includes each cited source's full page text — not the answer's own text. */ text?: boolean + outputSchema?: string | Record } export interface ExaAnswerResponse extends ToolResponse { output: { - answer: string + /** A string, or an object matching `outputSchema` when one is supplied. */ + answer: string | Record citations: { + id?: string title: string url: string - text: string + text?: string + author?: string + publishedDate?: string }[] + requestId?: string __costDollars?: ExaCostDollars } } -// Research tool types -export interface ExaResearchParams extends ExaBaseParams { +/** Effort levels the Agent API accepts, trading cost against depth. */ +export type ExaAgentEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'auto' + +export interface ExaAgentParams extends ExaBaseParams { query: string - model?: 'exa-research-fast' | 'exa-research' | 'exa-research-pro' + effort?: ExaAgentEffort + outputSchema?: string | Record + systemPrompt?: string + previousRunId?: string } -export interface ExaResearchResponse extends ToolResponse { +export interface ExaAgentResponse extends ToolResponse { output: { - taskId?: string - research: { + runId?: string + status?: string + stopReason?: string | null + text: string + structured?: unknown + grounding?: ExaGrounding[] + /** Legacy shape kept so workflows saved against the retired Research op resolve. */ + research?: { title: string url: string summary: string - text?: string - publishedDate?: string - author?: string + text: string score: number }[] + __costDollars?: ExaCostDollars } } @@ -180,4 +248,4 @@ export type ExaResponse = | ExaGetContentsResponse | ExaFindSimilarLinksResponse | ExaAnswerResponse - | ExaResearchResponse + | ExaAgentResponse diff --git a/apps/sim/tools/exa/utils.ts b/apps/sim/tools/exa/utils.ts new file mode 100644 index 00000000000..fdd70e97dca --- /dev/null +++ b/apps/sim/tools/exa/utils.ts @@ -0,0 +1,99 @@ +import type { ExaFreshnessParams } from '@/tools/exa/types' + +/** Splits a comma-separated user string into a trimmed, non-empty list. */ +export function parseCommaList(value: string | undefined): string[] | undefined { + if (!value) return undefined + const items = value + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0) + return items.length > 0 ? items : undefined +} + +/** + * Categories Exa retired when it reworked the taxonomy, mapped onto their + * current equivalents. Exa accepts `category` as an unvalidated soft hint, so a + * stale value never errors — it just stops steering results. Remapping keeps + * workflows saved against the old dropdown working as their authors intended. + * Values with no modern equivalent (`pdf`, `github`, `tweet`, `movie`, `song`) + * are passed through untouched. + */ +const LEGACY_CATEGORIES: Record = { + research_paper: 'publication', + 'research paper': 'publication', + news_article: 'news', + 'news article': 'news', + personal_site: 'personal site', + financial_report: 'financial report', + linkedin_profile: 'people', + 'linkedin profile': 'people', +} + +export function resolveCategory(category: string | undefined): string | undefined { + if (!category) return undefined + return LEGACY_CATEGORIES[category.toLowerCase()] ?? category +} + +/** + * Applies Exa's content-freshness controls to a request slice. + * + * Exa rejects a request that carries both `livecrawl` and `maxAgeHours` with a + * 400 (`Cannot set both 'livecrawl' and 'maxAgeHours'`), so exactly one may be + * sent. `maxAgeHours` is the current control and wins; `livecrawl` is kept only + * so workflows saved before the deprecation keep running unchanged. + */ +export function applyFreshness(target: Record, params: ExaFreshnessParams): void { + const maxAgeHours = params.maxAgeHours + const hasMaxAgeHours = + maxAgeHours !== undefined && maxAgeHours !== null && String(maxAgeHours).trim() !== '' + + if (hasMaxAgeHours) { + target.maxAgeHours = Number(maxAgeHours) + } else if (params.livecrawl) { + target.livecrawl = params.livecrawl + } + + if (params.livecrawlTimeout !== undefined && String(params.livecrawlTimeout).trim() !== '') { + target.livecrawlTimeout = Number(params.livecrawlTimeout) + } +} + +/** + * Normalizes a JSON Schema supplied through the UI, where it arrives as a + * string, or through an upstream block, where it is already an object. + */ +export function parseJsonSchema(value: unknown, label: string): Record | undefined { + if (value === undefined || value === null || value === '') return undefined + if (typeof value === 'object') return value as Record + if (typeof value !== 'string') return undefined + + try { + const parsed = JSON.parse(value) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('schema must be a JSON object') + } + return parsed as Record + } catch (error) { + throw new Error(`Invalid ${label}: ${(error as Error).message}`) + } +} + +/** Builds the `extras` slice, omitted entirely when nothing was requested. */ +export function buildExtras(params: { + extrasLinks?: number + extrasImageLinks?: number +}): Record | undefined { + const extras: Record = {} + if (params.extrasLinks) extras.links = Number(params.extrasLinks) + if (params.extrasImageLinks) extras.imageLinks = Number(params.extrasImageLinks) + return Object.keys(extras).length > 0 ? extras : undefined +} + +/** Reads `costDollars.total`, which Exa returns on every billable response. */ +export function requireCostTotal(output: Record, toolName: string): number { + const costDollars = output.__costDollars as { total?: number } | undefined + if (costDollars?.total == null) { + throw new Error(`Exa ${toolName} response missing costDollars field`) + } + return costDollars.total +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 1a1fa494ae6..6e1a9db0de7 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1007,10 +1007,10 @@ import { evernoteUpdateNoteTool, } from '@/tools/evernote' import { + exaAgentTool, exaAnswerTool, exaFindSimilarLinksTool, exaGetContentsTool, - exaResearchTool, exaSearchTool, } from '@/tools/exa' import { extendParserTool, extendParserV2Tool } from '@/tools/extend' @@ -6559,7 +6559,7 @@ export const tools: Record = { exa_get_contents: exaGetContentsTool, exa_find_similar_links: exaFindSimilarLinksTool, exa_answer: exaAnswerTool, - exa_research: exaResearchTool, + exa_agent: exaAgentTool, parallel_search: parallelSearchTool, parallel_extract: parallelExtractTool, parallel_deep_research: parallelDeepResearchTool,