Skip to content

Commit e3a57d3

Browse files
authored
feat(tools): add generic search tool (#2140)
1 parent f25db70 commit e3a57d3

File tree

16 files changed

+554
-197
lines changed

16 files changed

+554
-197
lines changed

apps/docs/components/icons.tsx

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -836,17 +836,18 @@ export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
836836

837837
export function LinkedInIcon(props: SVGProps<SVGSVGElement>) {
838838
return (
839-
<svg {...props} height='72' viewBox='0 0 72 72' width='72' xmlns='http://www.w3.org/2000/svg'>
840-
<g fill='none' fillRule='evenodd'>
841-
<path
842-
d='M8,72 L64,72 C68.418278,72 72,68.418278 72,64 L72,8 C72,3.581722 68.418278,-8.11624501e-16 64,0 L8,0 C3.581722,8.11624501e-16 -5.41083001e-16,3.581722 0,8 L0,64 C5.41083001e-16,68.418278 3.581722,72 8,72 Z'
843-
fill='#0072B1'
844-
/>
845-
<path
846-
d='M62,62 L51.315625,62 L51.315625,43.8021149 C51.315625,38.8127542 49.4197917,36.0245323 45.4707031,36.0245323 C41.1746094,36.0245323 38.9300781,38.9261103 38.9300781,43.8021149 L38.9300781,62 L28.6333333,62 L28.6333333,27.3333333 L38.9300781,27.3333333 L38.9300781,32.0029283 C38.9300781,32.0029283 42.0260417,26.2742151 49.3825521,26.2742151 C56.7356771,26.2742151 62,30.7644705 62,40.051212 L62,62 Z M16.349349,22.7940133 C12.8420573,22.7940133 10,19.9296567 10,16.3970067 C10,12.8643566 12.8420573,10 16.349349,10 C19.8566406,10 22.6970052,12.8643566 22.6970052,16.3970067 C22.6970052,19.9296567 19.8566406,22.7940133 16.349349,22.7940133 Z M11.0325521,62 L21.769401,62 L21.769401,27.3333333 L11.0325521,27.3333333 L11.0325521,62 Z'
847-
fill='#FFF'
848-
/>
849-
</g>
839+
<svg
840+
{...props}
841+
fill='currentColor'
842+
height='72'
843+
viewBox='0 0 72 72'
844+
width='72'
845+
xmlns='http://www.w3.org/2000/svg'
846+
>
847+
<path
848+
d='M62,62 L51.315625,62 L51.315625,43.8021149 C51.315625,38.8127542 49.4197917,36.0245323 45.4707031,36.0245323 C41.1746094,36.0245323 38.9300781,38.9261103 38.9300781,43.8021149 L38.9300781,62 L28.6333333,62 L28.6333333,27.3333333 L38.9300781,27.3333333 L38.9300781,32.0029283 C38.9300781,32.0029283 42.0260417,26.2742151 49.3825521,26.2742151 C56.7356771,26.2742151 62,30.7644705 62,40.051212 L62,62 Z M16.349349,22.7940133 C12.8420573,22.7940133 10,19.9296567 10,16.3970067 C10,12.8643566 12.8420573,10 16.349349,10 C19.8566406,10 22.6970052,12.8643566 22.6970052,16.3970067 C22.6970052,19.9296567 19.8566406,22.7940133 16.349349,22.7940133 Z M11.0325521,62 L21.769401,62 L21.769401,27.3333333 L11.0325521,27.3333333 L11.0325521,62 Z'
849+
fill='currentColor'
850+
/>
850851
</svg>
851852
)
852853
}
@@ -3833,3 +3834,32 @@ export function ApifyIcon(props: SVGProps<SVGSVGElement>) {
38333834
</svg>
38343835
)
38353836
}
3837+
3838+
interface StatusDotIconProps extends SVGProps<SVGSVGElement> {
3839+
status: 'operational' | 'degraded' | 'outage' | 'maintenance' | 'loading' | 'error'
3840+
}
3841+
3842+
export function StatusDotIcon({ status, className, ...props }: StatusDotIconProps) {
3843+
const colors = {
3844+
operational: '#10B981',
3845+
degraded: '#F59E0B',
3846+
outage: '#EF4444',
3847+
maintenance: '#3B82F6',
3848+
loading: '#9CA3AF',
3849+
error: '#9CA3AF',
3850+
}
3851+
3852+
return (
3853+
<svg
3854+
xmlns='http://www.w3.org/2000/svg'
3855+
width={6}
3856+
height={6}
3857+
viewBox='0 0 6 6'
3858+
fill='none'
3859+
className={className}
3860+
{...props}
3861+
>
3862+
<circle cx={3} cy={3} r={3} fill={colors[status]} />
3863+
</svg>
3864+
)
3865+
}

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
ResendIcon,
6969
S3Icon,
7070
SalesforceIcon,
71+
SearchIcon,
7172
SendgridIcon,
7273
SentryIcon,
7374
SerperIcon,
@@ -128,6 +129,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
128129
serper: SerperIcon,
129130
sentry: SentryIcon,
130131
sendgrid: SendgridIcon,
132+
search: SearchIcon,
131133
salesforce: SalesforceIcon,
132134
s3: S3Icon,
133135
resend: ResendIcon,

apps/docs/content/docs/en/tools/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"resend",
6464
"s3",
6565
"salesforce",
66+
"search",
6667
"sendgrid",
6768
"sentry",
6869
"serper",
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
title: Search
3+
description: Search the web ($0.01 per search)
4+
---
5+
6+
import { BlockInfoCard } from "@/components/ui/block-info-card"
7+
8+
<BlockInfoCard
9+
type="search"
10+
color="#3B82F6"
11+
/>
12+
13+
{/* MANUAL-CONTENT-START:intro */}
14+
The **Search** tool lets you search the web from within your Sim workflows using state-of-the-art search engines. Use it to pull in the latest information, news, facts, and web content directly into your agents, automations, or conversations.
15+
16+
- **General web search**: Find up-to-date information from the internet to supplement your workflows.
17+
- **Automated queries**: Let agents or program logic submit search queries and handle the results automatically.
18+
- **Structured results**: Returns the most relevant web results, including title, link, snippet, and date for each result.
19+
20+
> **Note:** Each search costs **$0.01** per query.
21+
22+
This tool is ideal for any workflow where your agents need access to live web data or must reference current events, perform research, or fetch supplemental content.
23+
{/* MANUAL-CONTENT-END */}
24+
25+
26+
## Usage Instructions
27+
28+
Search the web using the Search tool. Each search costs $0.01 per query.
29+
30+
31+
32+
## Tools
33+
34+
### `search_tool`
35+
36+
Search the web. Returns the most relevant web results, including title, link, snippet, and date for each result.
37+
38+
#### Input
39+
40+
| Parameter | Type | Required | Description |
41+
| --------- | ---- | -------- | ----------- |
42+
| `query` | string | Yes | The search query |
43+
44+
#### Output
45+
46+
| Parameter | Type | Description |
47+
| --------- | ---- | ----------- |
48+
| `results` | json | Search results |
49+
| `query` | string | The search query |
50+
| `totalResults` | number | Total number of results |
51+
| `source` | string | Search source \(exa\) |
52+
| `cost` | json | Cost information \($0.01\) |
53+
54+
55+
56+
## Notes
57+
58+
- Category: `tools`
59+
- Type: `search`
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { z } from 'zod'
3+
import { checkHybridAuth } from '@/lib/auth/hybrid'
4+
import { SEARCH_TOOL_COST } from '@/lib/billing/constants'
5+
import { env } from '@/lib/env'
6+
import { createLogger } from '@/lib/logs/console/logger'
7+
import { executeTool } from '@/tools'
8+
9+
const logger = createLogger('search')
10+
11+
const SearchRequestSchema = z.object({
12+
query: z.string().min(1),
13+
})
14+
15+
export const maxDuration = 60
16+
export const dynamic = 'force-dynamic'
17+
18+
export async function POST(request: NextRequest) {
19+
const requestId = crypto.randomUUID()
20+
21+
try {
22+
const { searchParams: urlParams } = new URL(request.url)
23+
const workflowId = urlParams.get('workflowId') || undefined
24+
25+
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
26+
27+
if (!authResult.success || !authResult.userId) {
28+
const errorMessage = workflowId ? 'Workflow not found' : authResult.error || 'Unauthorized'
29+
const statusCode = workflowId ? 404 : 401
30+
return NextResponse.json({ success: false, error: errorMessage }, { status: statusCode })
31+
}
32+
33+
const userId = authResult.userId
34+
35+
logger.info(`[${requestId}] Authenticated search request via ${authResult.authType}`, {
36+
userId,
37+
})
38+
39+
const body = await request.json()
40+
const validated = SearchRequestSchema.parse(body)
41+
42+
if (!env.EXA_API_KEY) {
43+
logger.error(`[${requestId}] EXA_API_KEY not configured`)
44+
return NextResponse.json(
45+
{ success: false, error: 'Search service not configured' },
46+
{ status: 503 }
47+
)
48+
}
49+
50+
logger.info(`[${requestId}] Executing search`, {
51+
userId,
52+
query: validated.query,
53+
})
54+
55+
const result = await executeTool('exa_search', {
56+
query: validated.query,
57+
type: 'auto',
58+
useAutoprompt: true,
59+
text: true,
60+
apiKey: env.EXA_API_KEY,
61+
})
62+
63+
if (!result.success) {
64+
logger.error(`[${requestId}] Search failed`, {
65+
userId,
66+
error: result.error,
67+
})
68+
return NextResponse.json(
69+
{
70+
success: false,
71+
error: result.error || 'Search failed',
72+
},
73+
{ status: 500 }
74+
)
75+
}
76+
77+
const results = (result.output.results || []).map((r: any, index: number) => ({
78+
title: r.title || '',
79+
link: r.url || '',
80+
snippet: r.text || '',
81+
date: r.publishedDate || undefined,
82+
position: index + 1,
83+
}))
84+
85+
const cost = {
86+
input: 0,
87+
output: 0,
88+
total: SEARCH_TOOL_COST,
89+
tokens: {
90+
prompt: 0,
91+
completion: 0,
92+
total: 0,
93+
},
94+
model: 'search-exa',
95+
pricing: {
96+
input: 0,
97+
cachedInput: 0,
98+
output: 0,
99+
updatedAt: new Date().toISOString(),
100+
},
101+
}
102+
103+
logger.info(`[${requestId}] Search completed`, {
104+
userId,
105+
resultCount: results.length,
106+
cost: cost.total,
107+
})
108+
109+
return NextResponse.json({
110+
results,
111+
query: validated.query,
112+
totalResults: results.length,
113+
source: 'exa',
114+
cost,
115+
})
116+
} catch (error: any) {
117+
logger.error(`[${requestId}] Search failed`, {
118+
error: error.message,
119+
stack: error.stack,
120+
})
121+
122+
return NextResponse.json(
123+
{
124+
success: false,
125+
error: error.message || 'Search failed',
126+
},
127+
{ status: 500 }
128+
)
129+
}
130+
}

0 commit comments

Comments
 (0)