Skip to content

Commit c5aef36

Browse files
committed
fix(langsmith,firecrawl): reverse two changes that made things worse
langsmith value: widening the param to 'json' made buildParameterSchema advertise {"type":"object"} to every model, so an agent could no longer send the ordinary scalar case the spec union exists for. Reverted to string. The real bug was elsewhere: parseLangsmithFeedbackValue ran only in the block's param mapper, so the LLM and direct-tool paths got no coercion at all. It now runs in request.body. Also closed three parser gaps -- the literal text 'null' was posting JSON null, and '1.0', '007' and long numeric ids lost their form. The round-trip guard is scoped to numbers so objects still parse. langsmith sessionId: the description invented a deprecation date. POST /api/v1/feedback is not deprecated and returns no sunset header; the 31 Jan 2027 date belongs to the run-read endpoint. Now quotes only the migration doc and the spec field description, and tells the caller never to guess the UUID, naming the two places it actually comes from. firecrawl search: flattening the source-keyed response into one array was the wrong call. news items carry snippet not description, images' url is the containing page while imageUrl is the image, position is per-source, and metadata is absent for images -- so the flattened array silently mislabelled or dropped every non-web field. The declared output is now the envelope Firecrawl documents, with three separately typed optional arrays. limit is per-source, so three sources at limit 100 returns up to 300 results; the description says so. Also: ignoreInvalidURLs was declared for search but its subBlock condition listed only batch_scrape.
1 parent f94b9c1 commit c5aef36

9 files changed

Lines changed: 402 additions & 145 deletions

File tree

apps/sim/blocks/blocks/firecrawl.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ Example 2 - Product Data:
300300
title: 'Ignore Invalid URLs',
301301
type: 'switch',
302302
mode: 'advanced',
303-
condition: { field: 'operation', value: 'batch_scrape' },
303+
condition: { field: 'operation', value: ['batch_scrape', 'search'] },
304304
},
305305
{
306306
id: 'waitFor',
@@ -554,6 +554,9 @@ Example 2 - Product Data:
554554
if (query) result.query = query
555555
if (timeout) result.timeout = Number.parseInt(timeout)
556556
if (limit) result.limit = Number.parseInt(limit)
557+
if (params.ignoreInvalidURLs != null) {
558+
result.ignoreInvalidURLs = params.ignoreInvalidURLs
559+
}
557560
break
558561

559562
case 'crawl':

apps/sim/blocks/blocks/langsmith.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ export const LangsmithBlock: BlockConfig<LangsmithResponse> = {
178178
id: 'session_id',
179179
title: 'Session ID',
180180
type: 'short-input',
181-
placeholder: 'Session identifier',
181+
placeholder: 'Tracing project (session) UUID, e.g. 018e4c7e-a9fb-7ef0-a5b6-6ea3a82e9327',
182182
condition: {
183183
field: 'operation',
184184
value: ['langsmith_create_run', 'langsmith_create_feedback'],
@@ -456,7 +456,8 @@ Common patch fields: outputs, end_time, status, error`,
456456
trace_id: { type: 'string', description: 'Trace ID' },
457457
session_id: {
458458
type: 'string',
459-
description: 'Tracing project (session) ID for the run or the feedback',
459+
description:
460+
'UUID of the tracing project (session) the run or the feedback belongs to. Required by LangSmith when creating feedback.',
460461
},
461462
session_name: { type: 'string', description: 'Session name' },
462463
status: { type: 'string', description: 'Run status' },

apps/sim/tools/firecrawl/search.test.ts

Lines changed: 102 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,9 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { flattenFirecrawlSearchResults, searchTool } from '@/tools/firecrawl/search'
5+
import { searchTool } from '@/tools/firecrawl/search'
66
import type { SearchParams } from '@/tools/firecrawl/types'
77

8-
const result = (url: string) => ({
9-
title: url,
10-
description: 'd',
11-
url,
12-
metadata: { sourceURL: url },
13-
})
14-
158
const jsonOk = (body: unknown) =>
169
new Response(JSON.stringify(body), {
1710
status: 200,
@@ -23,56 +16,91 @@ const searchParams: SearchParams = { apiKey: 'test-key', query: 'sim' }
2316
const resolveBody = (params: SearchParams): Record<string, unknown> =>
2417
searchTool.request.body!(params) as Record<string, unknown>
2518

26-
describe('firecrawl search result flattening', () => {
27-
it('flattens the default web-only envelope into the declared array', async () => {
19+
const dataOutputProperties = (): Record<string, any> =>
20+
(searchTool.outputs.data as { properties: Record<string, any> }).properties
21+
22+
describe('firecrawl search response shape', () => {
23+
it('keeps the source-keyed envelope Firecrawl returns', async () => {
2824
const response = await searchTool.transformResponse!(
29-
jsonOk({ data: { web: [result('https://a'), result('https://b')] }, creditsUsed: 2 }),
25+
jsonOk({
26+
success: true,
27+
data: {
28+
web: [{ title: 'w', description: 'd', url: 'https://web' }],
29+
news: [{ title: 'n', snippet: 's', url: 'https://news', position: 1 }],
30+
images: [{ title: 'i', imageUrl: 'https://img.png', url: 'https://page', position: 1 }],
31+
},
32+
creditsUsed: 3,
33+
id: 'job-1',
34+
warning: 'partial',
35+
}),
3036
searchParams
3137
)
3238

33-
expect(Array.isArray(response.output.data)).toBe(true)
34-
expect(response.output.data.map((item) => item.url)).toEqual(['https://a', 'https://b'])
35-
expect(response.output.creditsUsed).toBe(2)
39+
expect(response.output.data.web?.[0]?.url).toBe('https://web')
40+
expect(response.output.data.news?.[0]?.snippet).toBe('s')
41+
expect(response.output.data.images?.[0]?.imageUrl).toBe('https://img.png')
42+
expect(response.output.creditsUsed).toBe(3)
43+
expect(response.output.id).toBe('job-1')
44+
expect(response.output.warning).toBe('partial')
3645
})
3746

38-
it('concatenates multiple sources in web, news, images order', () => {
39-
const flattened = flattenFirecrawlSearchResults({
40-
images: [result('https://image')],
41-
news: [result('https://news')],
42-
web: [result('https://web')],
43-
})
47+
it('leaves unrequested source arrays absent rather than inventing empties', async () => {
48+
const response = await searchTool.transformResponse!(
49+
jsonOk({ success: true, data: { web: [] }, creditsUsed: 1 }),
50+
searchParams
51+
)
4452

45-
expect(flattened.map((item) => item.url)).toEqual([
46-
'https://web',
47-
'https://news',
48-
'https://image',
49-
])
53+
expect(response.output.data.web).toEqual([])
54+
expect(response.output.data.news).toBeUndefined()
55+
expect(response.output.data.images).toBeUndefined()
5056
})
5157

52-
it('appends unknown future source keys alphabetically after the known ones', () => {
53-
const flattened = flattenFirecrawlSearchResults({
54-
web: [result('https://web')],
55-
videos: [result('https://video')],
56-
podcasts: [result('https://podcast')],
57-
})
58+
it('yields an empty envelope when the payload carries no data object', async () => {
59+
const response = await searchTool.transformResponse!(jsonOk({ success: true }), searchParams)
60+
61+
expect(response.output.data).toEqual({})
62+
})
63+
})
64+
65+
describe('firecrawl search declared outputs', () => {
66+
it('declares data as a source-keyed object, not a flat array', () => {
67+
expect(searchTool.outputs.data.type).toBe('object')
68+
expect(Object.keys(dataOutputProperties()).sort()).toEqual(['images', 'news', 'web'])
69+
})
70+
71+
it('makes every source array optional, since which appear depends on sources', () => {
72+
for (const source of ['web', 'news', 'images']) {
73+
expect(dataOutputProperties()[source].optional, `${source} must be optional`).toBe(true)
74+
expect(dataOutputProperties()[source].type).toBe('array')
75+
}
76+
})
5877

59-
expect(flattened.map((item) => item.url)).toEqual([
60-
'https://web',
61-
'https://podcast',
62-
'https://video',
63-
])
78+
it('declares news items with snippet, the field news actually carries', () => {
79+
const news = dataOutputProperties().news.items.properties
80+
expect(news.snippet).toBeDefined()
81+
expect(news.description).toBeUndefined()
82+
expect(news.date).toBeDefined()
6483
})
6584

66-
it('passes a plain array through unchanged', () => {
67-
const flattened = flattenFirecrawlSearchResults([result('https://a')])
68-
expect(flattened.map((item) => item.url)).toEqual(['https://a'])
85+
it('declares imageUrl on image items and does not claim they carry metadata', () => {
86+
const images = dataOutputProperties().images.items.properties
87+
expect(images.imageUrl).toBeDefined()
88+
expect(images.imageWidth).toBeDefined()
89+
expect(images.metadata).toBeUndefined()
90+
expect(images.markdown).toBeUndefined()
6991
})
7092

71-
it('yields an empty array for a missing or non-object payload', () => {
72-
expect(flattenFirecrawlSearchResults(undefined)).toEqual([])
73-
expect(flattenFirecrawlSearchResults(null)).toEqual([])
74-
expect(flattenFirecrawlSearchResults('nope')).toEqual([])
75-
expect(flattenFirecrawlSearchResults({ web: 'not-an-array' })).toEqual([])
93+
it('declares web items with description and optional scraped metadata', () => {
94+
const web = dataOutputProperties().web.items.properties
95+
expect(web.description).toBeDefined()
96+
expect(web.snippet).toBeUndefined()
97+
expect(web.metadata.optional).toBe(true)
98+
})
99+
100+
it('declares the envelope fields the endpoint actually returns', () => {
101+
expect(searchTool.outputs.creditsUsed).toBeDefined()
102+
expect(searchTool.outputs.warning).toBeDefined()
103+
expect(searchTool.outputs.id).toBeDefined()
76104
})
77105
})
78106

@@ -96,6 +124,14 @@ describe('firecrawl search request params', () => {
96124
}
97125
})
98126

127+
it('documents limit as per-source, since it is not a total across sources', () => {
128+
expect(searchTool.params.limit.description).toMatch(/per source/i)
129+
})
130+
131+
it('does not promise that results are flattened into one array', () => {
132+
expect(searchTool.params.sources.description).not.toMatch(/flatten/i)
133+
})
134+
99135
it('sends the declared optional params on the wire', () => {
100136
const body = resolveBody({
101137
apiKey: 'test-key',
@@ -123,3 +159,25 @@ describe('firecrawl search request params', () => {
123159
})
124160
})
125161
})
162+
163+
describe('firecrawl block search wiring', () => {
164+
it('exposes ignoreInvalidURLs on search, which also accepts it', async () => {
165+
const { FirecrawlBlock } = await import('@/blocks/blocks/firecrawl')
166+
const subBlock = FirecrawlBlock.subBlocks.find((block) => block.id === 'ignoreInvalidURLs')
167+
168+
expect(subBlock?.condition).toMatchObject({ field: 'operation' })
169+
expect((subBlock?.condition as { value: string[] }).value).toContain('search')
170+
})
171+
172+
it('maps ignoreInvalidURLs into the search request params', async () => {
173+
const { FirecrawlBlock } = await import('@/blocks/blocks/firecrawl')
174+
const params = FirecrawlBlock.tools.config!.params!({
175+
operation: 'search',
176+
apiKey: 'k',
177+
query: 'sim',
178+
ignoreInvalidURLs: true,
179+
})
180+
181+
expect(params.ignoreInvalidURLs).toBe(true)
182+
})
183+
})

apps/sim/tools/firecrawl/search.ts

Lines changed: 25 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,48 +3,10 @@ import {
33
applyFirecrawlScrapeOptionsModelInput,
44
selectFirecrawlScrapeOptionsModelInput,
55
} from '@/tools/firecrawl/model-input'
6-
import type { SearchParams, SearchResponse, SearchResultItem } from '@/tools/firecrawl/types'
7-
import { SEARCH_RESULT_OUTPUT_PROPERTIES } from '@/tools/firecrawl/types'
6+
import type { FirecrawlSearchData, SearchParams, SearchResponse } from '@/tools/firecrawl/types'
7+
import { SEARCH_DATA_OUTPUT } from '@/tools/firecrawl/types'
88
import type { ToolConfig } from '@/tools/types'
99

10-
/**
11-
* Source keys Firecrawl documents for `POST /v2/search`, in the order their
12-
* results are concatenated. Listing them explicitly — rather than relying on
13-
* object key order — keeps the flattened output stable no matter what order
14-
* the API happens to serialize the envelope in.
15-
*/
16-
const FIRECRAWL_SEARCH_SOURCE_ORDER = ['web', 'news', 'images'] as const
17-
18-
/**
19-
* Flattens the source-keyed search envelope into the single result array this
20-
* tool declares.
21-
*
22-
* Firecrawl returns `data` keyed by source ("The arrays available will depend
23-
* on the sources you specified in the request. By default, the `web` array
24-
* will be returned."), so `data.data` is `{ web: [...], news: [...], images:
25-
* [...] }` — not the array `outputs.data` advertises. Known sources come first
26-
* in {@link FIRECRAWL_SEARCH_SOURCE_ORDER}, then any future source key in
27-
* alphabetical order; a plain array is passed through unchanged, and anything
28-
* else yields `[]`.
29-
*/
30-
export const flattenFirecrawlSearchResults = (data: unknown): SearchResultItem[] => {
31-
if (Array.isArray(data)) return data as SearchResultItem[]
32-
if (data === null || typeof data !== 'object') return []
33-
34-
const envelope = data as Record<string, unknown>
35-
const knownKeys = FIRECRAWL_SEARCH_SOURCE_ORDER as readonly string[]
36-
const extraKeys = Object.keys(envelope)
37-
.filter((key) => !knownKeys.includes(key))
38-
.sort()
39-
40-
const flattened: SearchResultItem[] = []
41-
for (const key of [...knownKeys, ...extraKeys]) {
42-
const results = envelope[key]
43-
if (Array.isArray(results)) flattened.push(...(results as SearchResultItem[]))
44-
}
45-
return flattened
46-
}
47-
4810
export const searchTool: ToolConfig<SearchParams, SearchResponse> = {
4911
id: 'firecrawl_search',
5012
name: 'Firecrawl Search',
@@ -62,14 +24,15 @@ export const searchTool: ToolConfig<SearchParams, SearchResponse> = {
6224
type: 'number',
6325
required: false,
6426
visibility: 'user-or-llm',
65-
description: 'Maximum number of results to return (Firecrawl default: 10)',
27+
description:
28+
'Maximum number of results to return per source type, not in total (Firecrawl default: 10, maximum: 100). Requesting three sources at limit 100 can return up to 300 results.',
6629
},
6730
sources: {
6831
type: 'array',
6932
required: false,
7033
visibility: 'user-or-llm',
7134
description:
72-
'Result sources to search. Defaults to ["web"]. Results from every requested source are flattened into `data` in web, news, images order.',
35+
'Result sources to search: "web", "news", and/or "images". Defaults to ["web"]. Each requested source is returned as its own array under `data` — `data.web`, `data.news`, `data.images` — with its own item fields.',
7336
items: { type: 'string' },
7437
},
7538
categories: {
@@ -167,25 +130,35 @@ export const searchTool: ToolConfig<SearchParams, SearchResponse> = {
167130
},
168131

169132
transformResponse: async (response: Response) => {
170-
const data = await response.json()
133+
const payload = await response.json()
134+
const data = payload?.data
171135

172136
return {
173137
success: true,
174138
output: {
175-
data: flattenFirecrawlSearchResults(data?.data),
176-
creditsUsed: data?.creditsUsed,
139+
data:
140+
data && typeof data === 'object' && !Array.isArray(data)
141+
? (data as FirecrawlSearchData)
142+
: {},
143+
warning: payload?.warning ?? undefined,
144+
id: payload?.id,
145+
creditsUsed: payload?.creditsUsed,
177146
},
178147
}
179148
},
180149

181150
outputs: {
182-
data: {
183-
type: 'array',
184-
description: 'Search results data with scraped content and metadata',
185-
items: {
186-
type: 'object',
187-
properties: SEARCH_RESULT_OUTPUT_PROPERTIES,
188-
},
151+
data: SEARCH_DATA_OUTPUT,
152+
warning: {
153+
type: 'string',
154+
description: 'Warning message if any issues occurred during the search',
155+
optional: true,
156+
},
157+
id: { type: 'string', description: 'ID of the search job', optional: true },
158+
creditsUsed: {
159+
type: 'number',
160+
description: 'Number of credits the search consumed',
161+
optional: true,
189162
},
190163
},
191164
}

0 commit comments

Comments
 (0)