Skip to content

Commit 95122fd

Browse files
committed
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.
1 parent 7016f85 commit 95122fd

6 files changed

Lines changed: 220 additions & 55 deletions

File tree

apps/docs/content/docs/en/integrations/exa.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,5 +235,6 @@ Run a deep research task with Exa Agent. Handles multi-step list building, enric
235235
| `text` | string | The written answer produced by the agent |
236236
| `structured` | json | Structured result matching outputSchema, when one was supplied |
237237
| `grounding` | json | Field-level citations backing the agent output |
238+
| `research` | array | The agent answer in the shape the retired Research operation emitted, so workflows that reference it keep resolving |
238239

239240

apps/sim/blocks/blocks/exa.ts

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,13 @@ const CATEGORY_OPTIONS = [
1717
/**
1818
* Exa retired `/research/v1` (HTTP 410) and replaced it with the Agent API.
1919
* Workflows saved against the old Research operation are routed to the Agent
20-
* tool so they keep running instead of failing against a dead endpoint.
20+
* tool so they keep running instead of failing against a dead endpoint. The
21+
* Agent operation's inputs are conditioned on both ids so the serializer keeps
22+
* carrying a stored research query forward — it drops any value whose sub-block
23+
* condition no longer matches.
2124
*/
2225
const LEGACY_RESEARCH_OPERATION = 'exa_research'
23-
24-
/** Maps the retired research models onto the Agent API's effort levels. */
25-
const RESEARCH_MODEL_TO_EFFORT: Record<string, string> = {
26-
'exa-research-fast': 'low',
27-
'exa-research': 'medium',
28-
'exa-research-pro': 'high',
29-
}
26+
const AGENT_OPERATIONS = ['exa_agent', LEGACY_RESEARCH_OPERATION]
3027

3128
export const ExaBlock: BlockConfig<ExaResponse> = {
3229
type: 'exa',
@@ -378,7 +375,7 @@ export const ExaBlock: BlockConfig<ExaResponse> = {
378375
title: 'Research Query',
379376
type: 'long-input',
380377
placeholder: 'Enter your research topic or question...',
381-
condition: { field: 'operation', value: 'exa_agent' },
378+
condition: { field: 'operation', value: AGENT_OPERATIONS },
382379
required: true,
383380
},
384381
{
@@ -394,7 +391,7 @@ export const ExaBlock: BlockConfig<ExaResponse> = {
394391
{ label: 'Extra High', id: 'xhigh' },
395392
],
396393
value: () => 'auto',
397-
condition: { field: 'operation', value: 'exa_agent' },
394+
condition: { field: 'operation', value: AGENT_OPERATIONS },
398395
},
399396
{
400397
id: 'outputSchema',
@@ -403,15 +400,15 @@ export const ExaBlock: BlockConfig<ExaResponse> = {
403400
language: 'json',
404401
placeholder: '{\n "type": "object",\n "properties": {}\n}',
405402
description: 'JSON Schema describing the structured result to return',
406-
condition: { field: 'operation', value: 'exa_agent' },
403+
condition: { field: 'operation', value: AGENT_OPERATIONS },
407404
mode: 'advanced',
408405
},
409406
{
410407
id: 'systemPrompt',
411408
title: 'System Prompt',
412409
type: 'long-input',
413410
placeholder: 'Guidance for how the agent should behave...',
414-
condition: { field: 'operation', value: 'exa_agent' },
411+
condition: { field: 'operation', value: AGENT_OPERATIONS },
415412
mode: 'advanced',
416413
},
417414
{
@@ -420,7 +417,7 @@ export const ExaBlock: BlockConfig<ExaResponse> = {
420417
type: 'short-input',
421418
placeholder: 'agent_run_...',
422419
description: 'Continue from a completed agent run for follow-up questions',
423-
condition: { field: 'operation', value: 'exa_agent' },
420+
condition: { field: 'operation', value: AGENT_OPERATIONS },
424421
mode: 'advanced',
425422
},
426423
// Find Similar Links operation inputs
@@ -559,10 +556,6 @@ export const ExaBlock: BlockConfig<ExaResponse> = {
559556
if (params.livecrawlTimeout) {
560557
result.livecrawlTimeout = Number(params.livecrawlTimeout)
561558
}
562-
/** Carry a retired research model over to the Agent API's effort scale. */
563-
if (params.operation === LEGACY_RESEARCH_OPERATION && params.model) {
564-
result.effort = RESEARCH_MODEL_TO_EFFORT[params.model as string] ?? 'medium'
565-
}
566559
return result
567560
},
568561
},
@@ -622,6 +615,10 @@ export const ExaBlock: BlockConfig<ExaResponse> = {
622615
stopReason: { type: 'string', description: 'Why the agent stopped' },
623616
text: { type: 'string', description: 'Agent written answer' },
624617
structured: { type: 'json', description: 'Agent structured result' },
618+
research: {
619+
type: 'json',
620+
description: 'Agent answer in the retired Research operation output shape',
621+
},
625622
},
626623
}
627624

apps/sim/tools/exa/agent.ts

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,9 @@ export const agentTool: ToolConfig<ExaAgentParams, ExaAgentResponse> = {
130130
return { ...result, success: false, error: 'Exa agent run did not return a run ID' }
131131
}
132132

133+
/** A run can already be terminal on creation, including a failed one. */
133134
if (TERMINAL_STATUSES.has(result.output.status ?? '')) {
134-
return finalize(result)
135+
return settle(result)
135136
}
136137

137138
logger.info(`Exa agent run ${runId} created, polling for completion`)
@@ -169,15 +170,7 @@ export const agentTool: ToolConfig<ExaAgentParams, ExaAgentResponse> = {
169170
__costDollars: runData.costDollars,
170171
}
171172

172-
if (runData.status !== 'completed') {
173-
return {
174-
...result,
175-
success: false,
176-
error: `Exa agent run ${runData.status}${runData.stopReason ? `: ${runData.stopReason}` : ''}`,
177-
}
178-
}
179-
180-
return finalize(result)
173+
return settle(result)
181174
} catch (error) {
182175
logger.error('Error polling Exa agent run status', {
183176
message: getErrorMessage(error, 'Unknown error'),
@@ -224,16 +217,68 @@ export const agentTool: ToolConfig<ExaAgentParams, ExaAgentResponse> = {
224217
description: 'Field-level citations backing the agent output',
225218
optional: true,
226219
},
220+
research: {
221+
type: 'array',
222+
description:
223+
'The agent answer in the shape the retired Research operation emitted, so workflows that reference it keep resolving',
224+
items: {
225+
type: 'object',
226+
properties: {
227+
title: { type: 'string' },
228+
url: { type: 'string' },
229+
summary: { type: 'string' },
230+
text: { type: 'string' },
231+
score: { type: 'number' },
232+
},
233+
},
234+
},
227235
},
228236
}
229237

230238
/**
231-
* A run that satisfies its schema can finish with an empty `text` body, so fall
232-
* back to the structured payload rather than returning a blank answer.
239+
* Resolves a terminal run into a tool result.
240+
*
241+
* A run can reach a terminal status either on creation or while polling, and a
242+
* `failed` or `cancelled` run must surface as a tool failure from both paths —
243+
* routing them through here keeps the two in step.
233244
*/
234-
function finalize(result: ExaAgentResponse): ExaAgentResponse {
245+
function settle(result: ExaAgentResponse): ExaAgentResponse {
246+
const { status, stopReason } = result.output
247+
248+
if (status !== 'completed') {
249+
return {
250+
...result,
251+
success: false,
252+
error: `Exa agent run ${status}${stopReason ? `: ${stopReason}` : ''}`,
253+
}
254+
}
255+
256+
/**
257+
* A run that satisfies its schema can finish with an empty `text` body, so
258+
* fall back to the structured payload rather than returning a blank answer.
259+
*/
235260
if (!result.output.text && result.output.structured !== undefined) {
236261
result.output.text = JSON.stringify(result.output.structured, null, 2)
237262
}
263+
264+
result.output.research = buildLegacyResearchOutput(result.output.text)
265+
238266
return result
239267
}
268+
269+
/**
270+
* Mirrors the one-element array the retired Research operation returned. Saved
271+
* workflows routed here from `exa_research` reference `research[0].text` and
272+
* `research[0].summary`, which would otherwise resolve to undefined.
273+
*/
274+
function buildLegacyResearchOutput(text: string) {
275+
return [
276+
{
277+
title: 'Research Complete',
278+
url: '',
279+
summary: text,
280+
text,
281+
score: 1,
282+
},
283+
]
284+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Guards backwards compatibility for Exa workflows saved before the API refresh.
3+
*
4+
* The serializer (`extractBlockParams`) decides which stored sub-block values
5+
* survive: a value whose sub-block config was removed is dropped, and a value
6+
* whose sub-block condition does not match the current operation is dropped too
7+
* (an `advanced` field still serializes when it holds a non-empty stored value).
8+
* These tests assert the block config satisfies those rules, since the config is
9+
* what drives that behavior.
10+
*
11+
* @vitest-environment node
12+
*/
13+
import { describe, expect, it } from 'vitest'
14+
import { ExaBlock } from '@/blocks/blocks/exa'
15+
import { agentTool } from '@/tools/exa/agent'
16+
import { searchTool } from '@/tools/exa/search'
17+
18+
/** Drives postProcess against a run that is already terminal on creation. */
19+
async function settleRun(status: string, stopReason: string | null = null) {
20+
return agentTool.postProcess?.(
21+
{
22+
success: true,
23+
output: {
24+
runId: 'agent_run_1',
25+
status,
26+
stopReason,
27+
text: status === 'completed' ? 'the answer' : '',
28+
},
29+
} as never,
30+
{ apiKey: 'k', query: 'q' } as never,
31+
{} as never
32+
)
33+
}
34+
35+
const subBlockIds = new Set(ExaBlock.subBlocks.map((subBlock) => subBlock.id))
36+
37+
function conditionValues(id: string): unknown[] {
38+
return ExaBlock.subBlocks
39+
.filter((subBlock) => subBlock.id === id)
40+
.flatMap((subBlock) => {
41+
const value = subBlock.condition?.value
42+
return Array.isArray(value) ? value : [value]
43+
})
44+
}
45+
46+
describe('legacy Exa workflow replay', () => {
47+
it('drops livecrawl and useAutoprompt, which no sub-block declares any more', () => {
48+
expect(subBlockIds.has('livecrawl')).toBe(false)
49+
expect(subBlockIds.has('useAutoprompt')).toBe(false)
50+
})
51+
52+
it('routes a saved research operation to the agent tool', () => {
53+
expect(ExaBlock.tools.config?.tool?.({ operation: 'exa_research' })).toBe('exa_agent')
54+
})
55+
56+
it('keeps carrying a stored research query, whose condition still matches', () => {
57+
expect(conditionValues('query')).toContain('exa_research')
58+
})
59+
60+
it('carries the agent inputs across for a replayed research operation', () => {
61+
for (const id of ['effort', 'outputSchema', 'systemPrompt', 'previousRunId']) {
62+
expect(conditionValues(id)).toContain('exa_research')
63+
}
64+
})
65+
66+
it('still declares the deprecated crawl-date filters so saved values survive', () => {
67+
expect(subBlockIds.has('startCrawlDate')).toBe(true)
68+
expect(subBlockIds.has('endCrawlDate')).toBe(true)
69+
})
70+
71+
it('still sends a legacy search type and remaps a retired category', () => {
72+
const body = searchTool.request.body?.({
73+
query: 'q',
74+
apiKey: 'k',
75+
type: 'neural',
76+
category: 'news_article',
77+
} as never) as Record<string, any>
78+
expect(body.type).toBe('neural')
79+
expect(body.category).toBe('news')
80+
})
81+
82+
it('emits the retired research output shape so downstream references resolve', async () => {
83+
const settled = await settleRun('completed')
84+
expect(settled?.output.research).toEqual([
85+
{ title: 'Research Complete', url: '', summary: 'the answer', text: 'the answer', score: 1 },
86+
])
87+
})
88+
89+
it('sends no freshness control when a workflow configured none', () => {
90+
const body = searchTool.request.body?.({
91+
query: 'q',
92+
apiKey: 'k',
93+
text: true,
94+
} as never) as Record<string, any>
95+
expect(body.contents.livecrawl).toBeUndefined()
96+
expect(body.contents.maxAgeHours).toBeUndefined()
97+
})
98+
})

apps/sim/tools/exa/exa.test.ts

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import { describe, expect, it } from 'vitest'
55
import { ExaBlock } from '@/blocks/blocks/exa'
6+
import { agentTool } from '@/tools/exa/agent'
67
import { answerTool } from '@/tools/exa/answer'
78
import { findSimilarLinksTool } from '@/tools/exa/find_similar_links'
89
import { getContentsTool } from '@/tools/exa/get_contents'
@@ -169,22 +170,6 @@ describe('exa block', () => {
169170
expect(ExaBlock.tools.config?.tool?.({ operation: 'exa_research' })).toBe('exa_agent')
170171
})
171172

172-
it('maps a saved research model onto an agent effort level', () => {
173-
const params = ExaBlock.tools.config?.params?.({
174-
operation: 'exa_research',
175-
model: 'exa-research-pro',
176-
}) as Record<string, unknown>
177-
expect(params.effort).toBe('high')
178-
})
179-
180-
it('does not leak an effort value onto non-research operations', () => {
181-
const params = ExaBlock.tools.config?.params?.({
182-
operation: 'exa_search',
183-
model: 'exa-research-pro',
184-
}) as Record<string, unknown>
185-
expect(params.effort).toBeUndefined()
186-
})
187-
188173
it('coerces maxAgeHours of 0 rather than dropping it as falsy', () => {
189174
const params = ExaBlock.tools.config?.params?.({
190175
operation: 'exa_search',
@@ -219,6 +204,46 @@ describe('exa block', () => {
219204
})
220205
})
221206

207+
describe('exa_agent terminal statuses', () => {
208+
const settle = (status: string, stopReason: string | null = null) =>
209+
agentTool.postProcess?.(
210+
{
211+
success: true,
212+
output: { runId: 'agent_run_1', status, stopReason, text: '', structured: { a: 1 } },
213+
} as never,
214+
{ apiKey: API_KEY, query: 'q' } as never,
215+
{} as never
216+
)
217+
218+
it('reports a run that is already failed on creation as a failure', async () => {
219+
const result = await settle('failed', 'error')
220+
expect(result?.success).toBe(false)
221+
expect(result?.error).toMatch(/failed: error/)
222+
})
223+
224+
it('reports a cancelled run as a failure', async () => {
225+
const result = await settle('cancelled')
226+
expect(result?.success).toBe(false)
227+
expect(result?.error).toMatch(/cancelled/)
228+
})
229+
230+
it('falls back to the structured payload when a completed run has no text', async () => {
231+
const result = await settle('completed')
232+
expect(result?.success).toBe(true)
233+
expect(result?.output.text).toBe(JSON.stringify({ a: 1 }, null, 2))
234+
})
235+
236+
it('fails when the create call returns no run ID', async () => {
237+
const result = await agentTool.postProcess?.(
238+
{ success: true, output: { text: '' } } as never,
239+
{ apiKey: API_KEY, query: 'q' } as never,
240+
{} as never
241+
)
242+
expect(result?.success).toBe(false)
243+
expect(result?.error).toMatch(/run ID/)
244+
})
245+
})
246+
222247
describe('find similar links', () => {
223248
it('is marked deprecated so new workflows prefer search', () => {
224249
expect(findSimilarLinksTool.description).toMatch(/deprecated/i)

0 commit comments

Comments
 (0)