From 7016f85658d62a60380cacefd16f7ae8f446de79 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 29 Jul 2026 17:36:39 -0700 Subject: [PATCH 1/5] feat(exa): refresh Exa integration against current API, retire dead research endpoint Exa's dev-rel team flagged that our integration was written against a retired version of their API. Validated every claim against the live API with a real key. - /research/v1 returns HTTP 410 RESEARCH_RETIRED, so the Research operation was hard-broken in production. Removed it and added an Agent operation on /agent/runs. Saved workflows on the old operation are routed to Agent so they start working again. - Category dropdown sent values Exa no longer recognizes (research_paper, news_article, movie, song, ...). Exa accepts category as an unvalidated soft hint, so these silently stopped steering results rather than erroring. Replaced with the current taxonomy and remapped legacy values. - Live crawl mode defaulted to 'never', silently forcing cache-only results on every search. Removed the default and exposed maxAgeHours, which replaces the deprecated livecrawl. Exa 400s when both are sent, so they are now mutually exclusive. - numResults was capped at 25 in the UI; the API allows 1-100. - Search types refreshed to instant/fast/auto/deep-lite/deep/ deep-reasoning. Legacy neural/keyword still pass through. - Exposed result id so search results can be chained into Get Contents via ids, plus highlightScores, subpages, entities, extras, statuses, requestId, and outputSchema structured output with grounding. - answer text controls cited-source text, not the answer; fixed the description and the dead query field. - Marked findSimilar and the crawl-date filters deprecated. Both still work, so existing workflows are unaffected. - Copilot search-online never requested page content, so every snippet was empty. Now requests highlights. --- .../docs/content/docs/en/integrations/exa.mdx | 109 ++++- apps/sim/blocks/blocks/exa.ts | 458 ++++++++++++------ .../tools/server/other/search-online.ts | 6 +- apps/sim/lib/integrations/integrations.json | 16 +- apps/sim/tools/exa/agent.ts | 239 +++++++++ apps/sim/tools/exa/answer.ts | 45 +- apps/sim/tools/exa/exa.test.ts | 226 +++++++++ apps/sim/tools/exa/find_similar_links.ts | 85 ++-- apps/sim/tools/exa/get_contents.ts | 144 ++++-- apps/sim/tools/exa/index.ts | 4 +- apps/sim/tools/exa/research.ts | 171 ------- apps/sim/tools/exa/search.ts | 224 ++++++--- apps/sim/tools/exa/types.ts | 223 ++++++--- apps/sim/tools/exa/utils.ts | 99 ++++ apps/sim/tools/registry.ts | 4 +- 15 files changed, 1483 insertions(+), 570 deletions(-) create mode 100644 apps/sim/tools/exa/agent.ts create mode 100644 apps/sim/tools/exa/exa.test.ts delete mode 100644 apps/sim/tools/exa/research.ts create mode 100644 apps/sim/tools/exa/utils.ts diff --git a/apps/docs/content/docs/en/integrations/exa.mdx b/apps/docs/content/docs/en/integrations/exa.mdx index c136b4f51cd..9957f09e3ff 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,42 @@ 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 | diff --git a/apps/sim/blocks/blocks/exa.ts b/apps/sim/blocks/blocks/exa.ts index ec016a6ae04..025b742bb52 100644 --- a/apps/sim/blocks/blocks/exa.ts +++ b/apps/sim/blocks/blocks/exa.ts @@ -3,13 +3,38 @@ 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. + */ +const LEGACY_RESEARCH_OPERATION = 'exa_research' + +/** Maps the retired research models onto the Agent API's effort levels. */ +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 +49,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 +71,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 +107,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 +133,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 +234,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,8 +256,17 @@ 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', }, { id: 'text', @@ -183,6 +274,13 @@ export const ExaBlock: BlockConfig = { 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 +312,117 @@ 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: 'exa_agent' }, + 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: 'exa_agent' }, + }, + { + 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: 'exa_agent' }, + mode: 'advanced', + }, + { + id: 'systemPrompt', + title: 'System Prompt', + type: 'long-input', + placeholder: 'Guidance for how the agent should behave...', + condition: { field: 'operation', value: 'exa_agent' }, + mode: 'advanced', + }, + { + 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: 'exa_agent' }, + mode: 'advanced', + }, // Find Similar Links operation inputs { id: 'url', @@ -263,18 +472,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 +492,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 +516,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 +531,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 +547,22 @@ 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. */ + if (params.operation === LEGACY_RESEARCH_OPERATION && params.model) { + result.effort = RESEARCH_MODEL_TO_EFFORT[params.model as string] ?? 'medium' + } return result }, }, @@ -408,7 +573,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 +580,48 @@ 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' }, + 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: 'Search or content results' }, + statuses: { type: 'json', description: 'Per-URL crawl outcome for Get Contents' }, + structuredOutput: { type: 'json', description: 'Synthesized output matching the schema' }, + grounding: { type: 'json', description: 'Field-level citations for generated output' }, + requestId: { type: 'string', description: 'Exa request identifier' }, // Find Similar Links output similarLinks: { type: 'json', description: 'Similar links found' }, // Answer output - answer: { type: 'string', description: 'Generated answer' }, + answer: { type: 'json', description: 'Generated answer' }, citations: { type: 'json', description: 'Answer citations' }, - // Research output - research: { type: 'json', description: 'Research findings' }, + // 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' }, }, } @@ -453,7 +633,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 +649,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 +660,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 +679,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 +698,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/tools/exa/agent.ts b/apps/sim/tools/exa/agent.ts new file mode 100644 index 00000000000..0be98a4f0b6 --- /dev/null +++ b/apps/sim/tools/exa/agent.ts @@ -0,0 +1,239 @@ +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' } + } + + if (TERMINAL_STATUSES.has(result.output.status ?? '')) { + return finalize(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, + } + + if (runData.status !== 'completed') { + return { + ...result, + success: false, + error: `Exa agent run ${runData.status}${runData.stopReason ? `: ${runData.stopReason}` : ''}`, + } + } + + return finalize(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, + }, + }, +} + +/** + * 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. + */ +function finalize(result: ExaAgentResponse): ExaAgentResponse { + if (!result.output.text && result.output.structured !== undefined) { + result.output.text = JSON.stringify(result.output.structured, null, 2) + } + return result +} 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..b69a38f68ec --- /dev/null +++ b/apps/sim/tools/exa/exa.test.ts @@ -0,0 +1,226 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { ExaBlock } from '@/blocks/blocks/exa' +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('maps a saved research model onto 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('does not leak an effort value onto non-research operations', () => { + const params = ExaBlock.tools.config?.params?.({ + operation: 'exa_search', + 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('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..7a52160fe88 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,130 +96,157 @@ 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: { - title: string - url: string - summary: string - text?: string - publishedDate?: string - author?: string - score: number - }[] + runId?: string + status?: string + stopReason?: string | null + text: string + structured?: unknown + grounding?: ExaGrounding[] + __costDollars?: ExaCostDollars } } +/** + * Legacy research params. Exa retired `/research/v1` (HTTP 410), so this tool + * now runs on the Agent API and maps its `model` onto an agent effort level. + */ +export interface ExaResearchParams extends ExaBaseParams { + query: string + model?: 'exa-research-fast' | 'exa-research' | 'exa-research-pro' +} + export type ExaResponse = | ExaSearchResponse | 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, From 95122fdda9d9f5a8dea396e8db7d357f016aa8a2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 29 Jul 2026 17:44:36 -0700 Subject: [PATCH 2/5] fix(exa): address review findings on the API refresh - A run already terminal on creation went through a path that never set success=false, so a failed or cancelled run reported as successful. Both the create path and the poll loop now settle through one function. - Routing exa_research to Agent dropped the research output shape, so saved workflows referencing research[0].text resolved to undefined. The agent tool now also emits that legacy shape. - The Agent operation's inputs are conditioned on both exa_agent and exa_research so the serializer keeps carrying a stored research query; it drops any value whose sub-block condition no longer matches. - Dropped the model to effort mapping and the unused ExaResearchParams: the serializer drops values for removed sub-blocks, so model never reached the params function. --- .../docs/content/docs/en/integrations/exa.mdx | 1 + apps/sim/blocks/blocks/exa.ts | 31 +++--- apps/sim/tools/exa/agent.ts | 71 +++++++++++--- apps/sim/tools/exa/exa-compat.test.ts | 98 +++++++++++++++++++ apps/sim/tools/exa/exa.test.ts | 57 ++++++++--- apps/sim/tools/exa/types.ts | 17 ++-- 6 files changed, 220 insertions(+), 55 deletions(-) create mode 100644 apps/sim/tools/exa/exa-compat.test.ts diff --git a/apps/docs/content/docs/en/integrations/exa.mdx b/apps/docs/content/docs/en/integrations/exa.mdx index 9957f09e3ff..509a74803c0 100644 --- a/apps/docs/content/docs/en/integrations/exa.mdx +++ b/apps/docs/content/docs/en/integrations/exa.mdx @@ -235,5 +235,6 @@ Run a deep research task with Exa Agent. Handles multi-step list building, enric | `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 025b742bb52..e0df2ad6c29 100644 --- a/apps/sim/blocks/blocks/exa.ts +++ b/apps/sim/blocks/blocks/exa.ts @@ -17,16 +17,13 @@ const CATEGORY_OPTIONS = [ /** * 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. + * 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' - -/** Maps the retired research models onto the Agent API's effort levels. */ -const RESEARCH_MODEL_TO_EFFORT: Record = { - 'exa-research-fast': 'low', - 'exa-research': 'medium', - 'exa-research-pro': 'high', -} +const AGENT_OPERATIONS = ['exa_agent', LEGACY_RESEARCH_OPERATION] export const ExaBlock: BlockConfig = { type: 'exa', @@ -378,7 +375,7 @@ export const ExaBlock: BlockConfig = { title: 'Research Query', type: 'long-input', placeholder: 'Enter your research topic or question...', - condition: { field: 'operation', value: 'exa_agent' }, + condition: { field: 'operation', value: AGENT_OPERATIONS }, required: true, }, { @@ -394,7 +391,7 @@ export const ExaBlock: BlockConfig = { { label: 'Extra High', id: 'xhigh' }, ], value: () => 'auto', - condition: { field: 'operation', value: 'exa_agent' }, + condition: { field: 'operation', value: AGENT_OPERATIONS }, }, { id: 'outputSchema', @@ -403,7 +400,7 @@ export const ExaBlock: BlockConfig = { language: 'json', placeholder: '{\n "type": "object",\n "properties": {}\n}', description: 'JSON Schema describing the structured result to return', - condition: { field: 'operation', value: 'exa_agent' }, + condition: { field: 'operation', value: AGENT_OPERATIONS }, mode: 'advanced', }, { @@ -411,7 +408,7 @@ export const ExaBlock: BlockConfig = { title: 'System Prompt', type: 'long-input', placeholder: 'Guidance for how the agent should behave...', - condition: { field: 'operation', value: 'exa_agent' }, + condition: { field: 'operation', value: AGENT_OPERATIONS }, mode: 'advanced', }, { @@ -420,7 +417,7 @@ export const ExaBlock: BlockConfig = { type: 'short-input', placeholder: 'agent_run_...', description: 'Continue from a completed agent run for follow-up questions', - condition: { field: 'operation', value: 'exa_agent' }, + condition: { field: 'operation', value: AGENT_OPERATIONS }, mode: 'advanced', }, // Find Similar Links operation inputs @@ -559,10 +556,6 @@ export const ExaBlock: BlockConfig = { if (params.livecrawlTimeout) { result.livecrawlTimeout = Number(params.livecrawlTimeout) } - /** Carry a retired research model over to the Agent API's effort scale. */ - if (params.operation === LEGACY_RESEARCH_OPERATION && params.model) { - result.effort = RESEARCH_MODEL_TO_EFFORT[params.model as string] ?? 'medium' - } return result }, }, @@ -622,6 +615,10 @@ export const ExaBlock: BlockConfig = { 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: 'Agent answer in the retired Research operation output shape', + }, }, } diff --git a/apps/sim/tools/exa/agent.ts b/apps/sim/tools/exa/agent.ts index 0be98a4f0b6..bd46276d0e1 100644 --- a/apps/sim/tools/exa/agent.ts +++ b/apps/sim/tools/exa/agent.ts @@ -130,8 +130,9 @@ export const agentTool: ToolConfig = { 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 finalize(result) + return settle(result) } logger.info(`Exa agent run ${runId} created, polling for completion`) @@ -169,15 +170,7 @@ export const agentTool: ToolConfig = { __costDollars: runData.costDollars, } - if (runData.status !== 'completed') { - return { - ...result, - success: false, - error: `Exa agent run ${runData.status}${runData.stopReason ? `: ${runData.stopReason}` : ''}`, - } - } - - return finalize(result) + return settle(result) } catch (error) { logger.error('Error polling Exa agent run status', { message: getErrorMessage(error, 'Unknown error'), @@ -224,16 +217,68 @@ export const agentTool: ToolConfig = { 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' }, + }, + }, + }, }, } /** - * 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. + * 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 finalize(result: ExaAgentResponse): ExaAgentResponse { +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/exa-compat.test.ts b/apps/sim/tools/exa/exa-compat.test.ts new file mode 100644 index 00000000000..4abc1f58d5c --- /dev/null +++ b/apps/sim/tools/exa/exa-compat.test.ts @@ -0,0 +1,98 @@ +/** + * Guards backwards compatibility for Exa workflows saved before the API refresh. + * + * The serializer (`extractBlockParams`) decides which stored sub-block values + * survive: a value whose sub-block config was removed is dropped, and a value + * whose sub-block condition does not match the current operation is dropped too + * (an `advanced` field still serializes when it holds a non-empty stored value). + * These tests assert the block config satisfies those rules, since the config is + * what drives that behavior. + * + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { ExaBlock } from '@/blocks/blocks/exa' +import { agentTool } from '@/tools/exa/agent' +import { searchTool } from '@/tools/exa/search' + +/** Drives postProcess against a run that is already terminal on creation. */ +async function settleRun(status: string, stopReason: string | null = null) { + return agentTool.postProcess?.( + { + success: true, + output: { + runId: 'agent_run_1', + status, + stopReason, + text: status === 'completed' ? 'the answer' : '', + }, + } as never, + { apiKey: 'k', query: 'q' } as never, + {} as never + ) +} + +const subBlockIds = new Set(ExaBlock.subBlocks.map((subBlock) => subBlock.id)) + +function conditionValues(id: string): unknown[] { + return ExaBlock.subBlocks + .filter((subBlock) => subBlock.id === id) + .flatMap((subBlock) => { + const value = subBlock.condition?.value + return Array.isArray(value) ? value : [value] + }) +} + +describe('legacy Exa workflow replay', () => { + it('drops livecrawl and useAutoprompt, which no sub-block declares any more', () => { + expect(subBlockIds.has('livecrawl')).toBe(false) + expect(subBlockIds.has('useAutoprompt')).toBe(false) + }) + + it('routes a saved research operation to the agent tool', () => { + expect(ExaBlock.tools.config?.tool?.({ operation: 'exa_research' })).toBe('exa_agent') + }) + + it('keeps carrying a stored research query, whose condition still matches', () => { + expect(conditionValues('query')).toContain('exa_research') + }) + + it('carries the agent inputs across for a replayed research operation', () => { + for (const id of ['effort', 'outputSchema', 'systemPrompt', 'previousRunId']) { + expect(conditionValues(id)).toContain('exa_research') + } + }) + + it('still declares the deprecated crawl-date filters so saved values survive', () => { + expect(subBlockIds.has('startCrawlDate')).toBe(true) + expect(subBlockIds.has('endCrawlDate')).toBe(true) + }) + + it('still sends a legacy search type and remaps a retired category', () => { + const body = searchTool.request.body?.({ + query: 'q', + apiKey: 'k', + type: 'neural', + category: 'news_article', + } as never) as Record + expect(body.type).toBe('neural') + expect(body.category).toBe('news') + }) + + it('emits the retired research output shape so downstream references resolve', async () => { + const settled = await settleRun('completed') + expect(settled?.output.research).toEqual([ + { title: 'Research Complete', url: '', summary: 'the answer', text: 'the answer', score: 1 }, + ]) + }) + + it('sends no freshness control when a workflow configured none', () => { + const body = searchTool.request.body?.({ + query: 'q', + apiKey: 'k', + text: true, + } as never) as Record + expect(body.contents.livecrawl).toBeUndefined() + expect(body.contents.maxAgeHours).toBeUndefined() + }) +}) diff --git a/apps/sim/tools/exa/exa.test.ts b/apps/sim/tools/exa/exa.test.ts index b69a38f68ec..8a630af28bf 100644 --- a/apps/sim/tools/exa/exa.test.ts +++ b/apps/sim/tools/exa/exa.test.ts @@ -3,6 +3,7 @@ */ 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' @@ -169,22 +170,6 @@ describe('exa block', () => { expect(ExaBlock.tools.config?.tool?.({ operation: 'exa_research' })).toBe('exa_agent') }) - it('maps a saved research model onto 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('does not leak an effort value onto non-research operations', () => { - const params = ExaBlock.tools.config?.params?.({ - operation: 'exa_search', - 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', @@ -219,6 +204,46 @@ describe('exa block', () => { }) }) +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('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/types.ts b/apps/sim/tools/exa/types.ts index 7a52160fe88..7f7e79c4cd7 100644 --- a/apps/sim/tools/exa/types.ts +++ b/apps/sim/tools/exa/types.ts @@ -231,19 +231,18 @@ export interface ExaAgentResponse extends ToolResponse { 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 + score: number + }[] __costDollars?: ExaCostDollars } } -/** - * Legacy research params. Exa retired `/research/v1` (HTTP 410), so this tool - * now runs on the Agent API and maps its `model` onto an agent effort level. - */ -export interface ExaResearchParams extends ExaBaseParams { - query: string - model?: 'exa-research-fast' | 'exa-research' | 'exa-research-pro' -} - export type ExaResponse = | ExaSearchResponse | ExaGetContentsResponse From 914c9b23bf60133b06ff38b2c632509d94581d0d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 29 Jul 2026 17:53:45 -0700 Subject: [PATCH 3/5] fix(exa): keep legacy research model, add subblock migrations, sharpen outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subblock ID stability check caught the removed subblocks — that gate exists precisely to stop removals from breaking deployed workflows. - Restore the research model sub-block, scoped to the legacy exa_research operation so it never shows for new workflows but still serializes for saved ones, and restore the model to effort mapping. Removing it lost the configured research depth, silently falling back to effort auto. - Register useAutoprompt and livecrawl in SUBBLOCK_ID_MIGRATIONS as intentional removals. Neither has a value-compatible replacement: livecrawl is a mode string and maxAgeHours a number, so mapping one to the other would send NaN. - Replace vague json output descriptions with their inner field lists. - Drop the separate compat test file; the coverage that guards real regressions now lives in exa.test.ts. --- apps/sim/blocks/blocks/exa.ts | 68 +++++++++++-- .../migrations/subblock-migrations.ts | 12 +++ apps/sim/tools/exa/exa-compat.test.ts | 98 ------------------- apps/sim/tools/exa/exa.test.ts | 35 +++++++ 4 files changed, 106 insertions(+), 107 deletions(-) delete mode 100644 apps/sim/tools/exa/exa-compat.test.ts diff --git a/apps/sim/blocks/blocks/exa.ts b/apps/sim/blocks/blocks/exa.ts index e0df2ad6c29..330d603dcf6 100644 --- a/apps/sim/blocks/blocks/exa.ts +++ b/apps/sim/blocks/blocks/exa.ts @@ -25,6 +25,16 @@ const CATEGORY_OPTIONS = [ 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', @@ -411,6 +421,18 @@ export const ExaBlock: BlockConfig = { 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', + condition: { field: 'operation', value: LEGACY_RESEARCH_OPERATION }, + }, { id: 'previousRunId', title: 'Previous Run ID', @@ -556,6 +578,10 @@ export const ExaBlock: BlockConfig = { if (params.livecrawlTimeout) { result.livecrawlTimeout = Number(params.livecrawlTimeout) } + /** Carry a retired research model over to the Agent API's effort scale. */ + if (params.operation === LEGACY_RESEARCH_OPERATION && params.model) { + result.effort = RESEARCH_MODEL_TO_EFFORT[params.model as string] ?? 'medium' + } return result }, }, @@ -595,20 +621,43 @@ export const ExaBlock: BlockConfig = { excludeSourceDomain: { type: 'boolean', description: 'Exclude source domain' }, // 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 and Get Contents output - results: { type: 'json', description: 'Search or content results' }, - statuses: { type: 'json', description: 'Per-URL crawl outcome for Get Contents' }, - structuredOutput: { type: 'json', description: 'Synthesized output matching the schema' }, - grounding: { type: 'json', description: 'Field-level citations for generated output' }, - requestId: { type: 'string', description: 'Exa request identifier' }, + 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: 'json', description: 'Generated answer' }, - citations: { type: 'json', description: 'Answer citations' }, + 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' }, @@ -617,7 +666,8 @@ export const ExaBlock: BlockConfig = { structured: { type: 'json', description: 'Agent structured result' }, research: { type: 'json', - description: 'Agent answer in the retired Research operation output shape', + description: + '[{title, url, summary, text, score}] — the agent answer in the retired Research operation shape, so saved workflows keep resolving', }, }, } 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/exa-compat.test.ts b/apps/sim/tools/exa/exa-compat.test.ts deleted file mode 100644 index 4abc1f58d5c..00000000000 --- a/apps/sim/tools/exa/exa-compat.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Guards backwards compatibility for Exa workflows saved before the API refresh. - * - * The serializer (`extractBlockParams`) decides which stored sub-block values - * survive: a value whose sub-block config was removed is dropped, and a value - * whose sub-block condition does not match the current operation is dropped too - * (an `advanced` field still serializes when it holds a non-empty stored value). - * These tests assert the block config satisfies those rules, since the config is - * what drives that behavior. - * - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { ExaBlock } from '@/blocks/blocks/exa' -import { agentTool } from '@/tools/exa/agent' -import { searchTool } from '@/tools/exa/search' - -/** Drives postProcess against a run that is already terminal on creation. */ -async function settleRun(status: string, stopReason: string | null = null) { - return agentTool.postProcess?.( - { - success: true, - output: { - runId: 'agent_run_1', - status, - stopReason, - text: status === 'completed' ? 'the answer' : '', - }, - } as never, - { apiKey: 'k', query: 'q' } as never, - {} as never - ) -} - -const subBlockIds = new Set(ExaBlock.subBlocks.map((subBlock) => subBlock.id)) - -function conditionValues(id: string): unknown[] { - return ExaBlock.subBlocks - .filter((subBlock) => subBlock.id === id) - .flatMap((subBlock) => { - const value = subBlock.condition?.value - return Array.isArray(value) ? value : [value] - }) -} - -describe('legacy Exa workflow replay', () => { - it('drops livecrawl and useAutoprompt, which no sub-block declares any more', () => { - expect(subBlockIds.has('livecrawl')).toBe(false) - expect(subBlockIds.has('useAutoprompt')).toBe(false) - }) - - it('routes a saved research operation to the agent tool', () => { - expect(ExaBlock.tools.config?.tool?.({ operation: 'exa_research' })).toBe('exa_agent') - }) - - it('keeps carrying a stored research query, whose condition still matches', () => { - expect(conditionValues('query')).toContain('exa_research') - }) - - it('carries the agent inputs across for a replayed research operation', () => { - for (const id of ['effort', 'outputSchema', 'systemPrompt', 'previousRunId']) { - expect(conditionValues(id)).toContain('exa_research') - } - }) - - it('still declares the deprecated crawl-date filters so saved values survive', () => { - expect(subBlockIds.has('startCrawlDate')).toBe(true) - expect(subBlockIds.has('endCrawlDate')).toBe(true) - }) - - it('still sends a legacy search type and remaps a retired category', () => { - const body = searchTool.request.body?.({ - query: 'q', - apiKey: 'k', - type: 'neural', - category: 'news_article', - } as never) as Record - expect(body.type).toBe('neural') - expect(body.category).toBe('news') - }) - - it('emits the retired research output shape so downstream references resolve', async () => { - const settled = await settleRun('completed') - expect(settled?.output.research).toEqual([ - { title: 'Research Complete', url: '', summary: 'the answer', text: 'the answer', score: 1 }, - ]) - }) - - it('sends no freshness control when a workflow configured none', () => { - const body = searchTool.request.body?.({ - query: 'q', - apiKey: 'k', - text: true, - } as never) as Record - expect(body.contents.livecrawl).toBeUndefined() - expect(body.contents.maxAgeHours).toBeUndefined() - }) -}) diff --git a/apps/sim/tools/exa/exa.test.ts b/apps/sim/tools/exa/exa.test.ts index 8a630af28bf..24345380033 100644 --- a/apps/sim/tools/exa/exa.test.ts +++ b/apps/sim/tools/exa/exa.test.ts @@ -170,6 +170,27 @@ describe('exa block', () => { 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 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', @@ -227,6 +248,20 @@ describe('exa_agent terminal statuses', () => { 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) From af94705f5287e41a21f568be3d9a172eabbba531 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 29 Jul 2026 18:01:43 -0700 Subject: [PATCH 4/5] fix(exa): flag empty Get Contents configs in the editor, keep legacy research depth - A saved research workflow with no stored model fell through to the Agent default of auto rather than the standard depth the old Research operation used. Legacy research now always maps to an effort level, defaulting to medium, and the legacy model sub-block carries the same default the old dropdown had. - Get Contents needed both selectors optional so the ids path is reachable, which left an empty config failing only at run time. URLs is now conditionally required, dropping the requirement when result IDs are supplied, so the editor flags the empty case. The exactly-one check in the request body stays as the backstop. --- apps/sim/blocks/blocks/exa.ts | 26 ++++++++++++++++++++++++-- apps/sim/tools/exa/exa.test.ts | 17 +++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/sim/blocks/blocks/exa.ts b/apps/sim/blocks/blocks/exa.ts index 330d603dcf6..06bbaacef7d 100644 --- a/apps/sim/blocks/blocks/exa.ts +++ b/apps/sim/blocks/blocks/exa.ts @@ -22,6 +22,11 @@ const CATEGORY_OPTIONS = [ * carrying a stored research query forward — it drops any value whose sub-block * condition no longer matches. */ +/** True when a sub-block holds a non-empty user value. */ +function hasValue(value: unknown): boolean { + return typeof value === 'string' ? value.trim().length > 0 : value != null +} + const LEGACY_RESEARCH_OPERATION = 'exa_research' const AGENT_OPERATIONS = ['exa_agent', LEGACY_RESEARCH_OPERATION] @@ -265,6 +270,17 @@ export const ExaBlock: BlockConfig = { 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' }, + /** + * Exactly one selector is needed. Inverting the operation match when + * Result IDs are present makes the requirement fall away for this + * operation, so the editor flags an empty config without blocking the + * ids-only path. + */ + required: (values) => ({ + field: 'operation', + value: 'exa_get_contents', + not: hasValue(values?.ids), + }), }, { id: 'ids', @@ -431,6 +447,7 @@ export const ExaBlock: BlockConfig = { { 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 }, }, { @@ -578,8 +595,13 @@ export const ExaBlock: BlockConfig = { if (params.livecrawlTimeout) { result.livecrawlTimeout = Number(params.livecrawlTimeout) } - /** Carry a retired research model over to the Agent API's effort scale. */ - if (params.operation === LEGACY_RESEARCH_OPERATION && params.model) { + /** + * 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 diff --git a/apps/sim/tools/exa/exa.test.ts b/apps/sim/tools/exa/exa.test.ts index 24345380033..120d6c43f76 100644 --- a/apps/sim/tools/exa/exa.test.ts +++ b/apps/sim/tools/exa/exa.test.ts @@ -178,6 +178,23 @@ describe('exa block', () => { 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('requires URLs on Get Contents when no result IDs are supplied', () => { + const urls = ExaBlock.subBlocks.find( + (block) => block.id === 'urls' && block.condition?.value === 'exa_get_contents' + ) + const required = urls?.required as (values?: Record) => { not?: boolean } + expect(required({}).not).toBe(false) + expect(required({ ids: ' ' }).not).toBe(false) + expect(required({ ids: 'id-1' }).not).toBe(true) + }) + 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') From 8e5a779b4ad2010cad2cfc7d91a939dec3a13fc6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 29 Jul 2026 18:08:53 -0700 Subject: [PATCH 5/5] fix(exa): revert conditional required on Get Contents URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conditional required callback did not work and introduced a regression. `isFieldRequired` in webhook deploy calls `config.required()` with no arguments, so the callback never saw `ids` and left URLs required — an ids-only block would have been reported as missing a required field on deploy. `collectBlockFieldIssues` skips sub-block required checks whose id matches a tool param, so it never evaluated the callback either. Both selectors go back to optional with the exactly-one check in the request body, which is what the integration rules prescribe for mutually exclusive alternate identifiers. Added a comment recording why a conditional required cannot express this, so it is not reattempted. --- apps/sim/blocks/blocks/exa.ts | 24 ++++++++---------------- apps/sim/tools/exa/exa.test.ts | 15 +++++++-------- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/apps/sim/blocks/blocks/exa.ts b/apps/sim/blocks/blocks/exa.ts index 06bbaacef7d..60c87c8c172 100644 --- a/apps/sim/blocks/blocks/exa.ts +++ b/apps/sim/blocks/blocks/exa.ts @@ -22,11 +22,6 @@ const CATEGORY_OPTIONS = [ * carrying a stored research query forward — it drops any value whose sub-block * condition no longer matches. */ -/** True when a sub-block holds a non-empty user value. */ -function hasValue(value: unknown): boolean { - return typeof value === 'string' ? value.trim().length > 0 : value != null -} - const LEGACY_RESEARCH_OPERATION = 'exa_research' const AGENT_OPERATIONS = ['exa_agent', LEGACY_RESEARCH_OPERATION] @@ -270,17 +265,6 @@ export const ExaBlock: BlockConfig = { 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' }, - /** - * Exactly one selector is needed. Inverting the operation match when - * Result IDs are present makes the requirement fall away for this - * operation, so the editor flags an empty config without blocking the - * ids-only path. - */ - required: (values) => ({ - field: 'operation', - value: 'exa_get_contents', - not: hasValue(values?.ids), - }), }, { id: 'ids', @@ -291,6 +275,14 @@ export const ExaBlock: BlockConfig = { 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', diff --git a/apps/sim/tools/exa/exa.test.ts b/apps/sim/tools/exa/exa.test.ts index 120d6c43f76..cd22af58ee3 100644 --- a/apps/sim/tools/exa/exa.test.ts +++ b/apps/sim/tools/exa/exa.test.ts @@ -185,14 +185,13 @@ describe('exa block', () => { expect(params.effort).toBe('medium') }) - it('requires URLs on Get Contents when no result IDs are supplied', () => { - const urls = ExaBlock.subBlocks.find( - (block) => block.id === 'urls' && block.condition?.value === 'exa_get_contents' - ) - const required = urls?.required as (values?: Record) => { not?: boolean } - expect(required({}).not).toBe(false) - expect(required({ ids: ' ' }).not).toBe(false) - expect(required({ ids: 'id-1' }).not).toBe(true) + 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', () => {