feat(tools): add generic search tool (#2140)

This commit is contained in:
Waleed
2025-11-30 14:13:46 -08:00
committed by GitHub
parent f25db707d7
commit e3a57d30e8
16 changed files with 553 additions and 196 deletions
+41 -11
View File
@@ -836,17 +836,18 @@ export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
export function LinkedInIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} height='72' viewBox='0 0 72 72' width='72' xmlns='http://www.w3.org/2000/svg'>
<g fill='none' fillRule='evenodd'>
<path
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'
fill='#0072B1'
/>
<path
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'
fill='#FFF'
/>
</g>
<svg
{...props}
fill='currentColor'
height='72'
viewBox='0 0 72 72'
width='72'
xmlns='http://www.w3.org/2000/svg'
>
<path
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'
fill='currentColor'
/>
</svg>
)
}
@@ -3833,3 +3834,32 @@ export function ApifyIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
interface StatusDotIconProps extends SVGProps<SVGSVGElement> {
status: 'operational' | 'degraded' | 'outage' | 'maintenance' | 'loading' | 'error'
}
export function StatusDotIcon({ status, className, ...props }: StatusDotIconProps) {
const colors = {
operational: '#10B981',
degraded: '#F59E0B',
outage: '#EF4444',
maintenance: '#3B82F6',
loading: '#9CA3AF',
error: '#9CA3AF',
}
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width={6}
height={6}
viewBox='0 0 6 6'
fill='none'
className={className}
{...props}
>
<circle cx={3} cy={3} r={3} fill={colors[status]} />
</svg>
)
}
+2
View File
@@ -68,6 +68,7 @@ import {
ResendIcon,
S3Icon,
SalesforceIcon,
SearchIcon,
SendgridIcon,
SentryIcon,
SerperIcon,
@@ -128,6 +129,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
serper: SerperIcon,
sentry: SentryIcon,
sendgrid: SendgridIcon,
search: SearchIcon,
salesforce: SalesforceIcon,
s3: S3Icon,
resend: ResendIcon,
@@ -63,6 +63,7 @@
"resend",
"s3",
"salesforce",
"search",
"sendgrid",
"sentry",
"serper",
@@ -0,0 +1,59 @@
---
title: Search
description: Search the web ($0.01 per search)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="search"
color="#3B82F6"
/>
{/* MANUAL-CONTENT-START:intro */}
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.
- **General web search**: Find up-to-date information from the internet to supplement your workflows.
- **Automated queries**: Let agents or program logic submit search queries and handle the results automatically.
- **Structured results**: Returns the most relevant web results, including title, link, snippet, and date for each result.
> **Note:** Each search costs **$0.01** per query.
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.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Search the web using the Search tool. Each search costs $0.01 per query.
## Tools
### `search_tool`
Search the web. Returns the most relevant web results, including title, link, snippet, and date for each result.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `query` | string | Yes | The search query |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `results` | json | Search results |
| `query` | string | The search query |
| `totalResults` | number | Total number of results |
| `source` | string | Search source \(exa\) |
| `cost` | json | Cost information \($0.01\) |
## Notes
- Category: `tools`
- Type: `search`
+130
View File
@@ -0,0 +1,130 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { SEARCH_TOOL_COST } from '@/lib/billing/constants'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { executeTool } from '@/tools'
const logger = createLogger('search')
const SearchRequestSchema = z.object({
query: z.string().min(1),
})
export const maxDuration = 60
export const dynamic = 'force-dynamic'
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID()
try {
const { searchParams: urlParams } = new URL(request.url)
const workflowId = urlParams.get('workflowId') || undefined
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
const errorMessage = workflowId ? 'Workflow not found' : authResult.error || 'Unauthorized'
const statusCode = workflowId ? 404 : 401
return NextResponse.json({ success: false, error: errorMessage }, { status: statusCode })
}
const userId = authResult.userId
logger.info(`[${requestId}] Authenticated search request via ${authResult.authType}`, {
userId,
})
const body = await request.json()
const validated = SearchRequestSchema.parse(body)
if (!env.EXA_API_KEY) {
logger.error(`[${requestId}] EXA_API_KEY not configured`)
return NextResponse.json(
{ success: false, error: 'Search service not configured' },
{ status: 503 }
)
}
logger.info(`[${requestId}] Executing search`, {
userId,
query: validated.query,
})
const result = await executeTool('exa_search', {
query: validated.query,
type: 'auto',
useAutoprompt: true,
text: true,
apiKey: env.EXA_API_KEY,
})
if (!result.success) {
logger.error(`[${requestId}] Search failed`, {
userId,
error: result.error,
})
return NextResponse.json(
{
success: false,
error: result.error || 'Search failed',
},
{ status: 500 }
)
}
const results = (result.output.results || []).map((r: any, index: number) => ({
title: r.title || '',
link: r.url || '',
snippet: r.text || '',
date: r.publishedDate || undefined,
position: index + 1,
}))
const cost = {
input: 0,
output: 0,
total: SEARCH_TOOL_COST,
tokens: {
prompt: 0,
completion: 0,
total: 0,
},
model: 'search-exa',
pricing: {
input: 0,
cachedInput: 0,
output: 0,
updatedAt: new Date().toISOString(),
},
}
logger.info(`[${requestId}] Search completed`, {
userId,
resultCount: results.length,
cost: cost.total,
})
return NextResponse.json({
results,
query: validated.query,
totalResults: results.length,
source: 'exa',
cost,
})
} catch (error: any) {
logger.error(`[${requestId}] Search failed`, {
error: error.message,
stack: error.stack,
})
return NextResponse.json(
{
success: false,
error: error.message || 'Search failed',
},
{ status: 500 }
)
}
}
@@ -11,6 +11,45 @@ import {
mockTriggerDevSdk,
} from '@/app/api/__test-utils__/utils'
const {
hasProcessedMessageMock,
markMessageAsProcessedMock,
closeRedisConnectionMock,
acquireLockMock,
generateRequestHashMock,
validateSlackSignatureMock,
handleWhatsAppVerificationMock,
handleSlackChallengeMock,
processWhatsAppDeduplicationMock,
processGenericDeduplicationMock,
fetchAndProcessAirtablePayloadsMock,
processWebhookMock,
executeMock,
} = vi.hoisted(() => ({
hasProcessedMessageMock: vi.fn().mockResolvedValue(false),
markMessageAsProcessedMock: vi.fn().mockResolvedValue(true),
closeRedisConnectionMock: vi.fn().mockResolvedValue(undefined),
acquireLockMock: vi.fn().mockResolvedValue(true),
generateRequestHashMock: vi.fn().mockResolvedValue('test-hash-123'),
validateSlackSignatureMock: vi.fn().mockResolvedValue(true),
handleWhatsAppVerificationMock: vi.fn().mockResolvedValue(null),
handleSlackChallengeMock: vi.fn().mockReturnValue(null),
processWhatsAppDeduplicationMock: vi.fn().mockResolvedValue(null),
processGenericDeduplicationMock: vi.fn().mockResolvedValue(null),
fetchAndProcessAirtablePayloadsMock: vi.fn().mockResolvedValue(undefined),
processWebhookMock: vi.fn().mockResolvedValue(new Response('Webhook processed', { status: 200 })),
executeMock: vi.fn().mockResolvedValue({
success: true,
output: { response: 'Webhook execution success' },
logs: [],
metadata: {
duration: 100,
startTime: new Date().toISOString(),
endTime: new Date().toISOString(),
},
}),
}))
vi.mock('@trigger.dev/sdk', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
@@ -32,31 +71,6 @@ vi.mock('@/background/logs-webhook-delivery', () => ({
logsWebhookDelivery: {},
}))
const hasProcessedMessageMock = vi.fn().mockResolvedValue(false)
const markMessageAsProcessedMock = vi.fn().mockResolvedValue(true)
const closeRedisConnectionMock = vi.fn().mockResolvedValue(undefined)
const acquireLockMock = vi.fn().mockResolvedValue(true)
const generateRequestHashMock = vi.fn().mockResolvedValue('test-hash-123')
const validateSlackSignatureMock = vi.fn().mockResolvedValue(true)
const handleWhatsAppVerificationMock = vi.fn().mockResolvedValue(null)
const handleSlackChallengeMock = vi.fn().mockReturnValue(null)
const processWhatsAppDeduplicationMock = vi.fn().mockResolvedValue(null)
const processGenericDeduplicationMock = vi.fn().mockResolvedValue(null)
const fetchAndProcessAirtablePayloadsMock = vi.fn().mockResolvedValue(undefined)
const processWebhookMock = vi
.fn()
.mockResolvedValue(new Response('Webhook processed', { status: 200 }))
const executeMock = vi.fn().mockResolvedValue({
success: true,
output: { response: 'Webhook execution success' },
logs: [],
metadata: {
duration: 100,
startTime: new Date().toISOString(),
endTime: new Date().toISOString(),
},
})
vi.mock('@/lib/redis', () => ({
hasProcessedMessage: hasProcessedMessageMock,
markMessageAsProcessed: markMessageAsProcessedMock,
@@ -76,9 +90,6 @@ vi.mock('@/lib/webhooks/utils', () => ({
vi.mock('@/app/api/webhooks/utils', () => ({
generateRequestHash: generateRequestHashMock,
}))
vi.mock('@/app/api/webhooks/utils', () => ({
validateSlackSignature: validateSlackSignatureMock,
}))
@@ -117,7 +128,47 @@ vi.mock('@/lib/logs/execution/logging-session', () => ({
})),
}))
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'
vi.mock('@/lib/workspaces/utils', async () => {
const actual = await vi.importActual('@/lib/workspaces/utils')
return {
...(actual as Record<string, unknown>),
getWorkspaceBilledAccountUserId: vi
.fn()
.mockImplementation(async (workspaceId: string | null | undefined) =>
workspaceId ? 'test-user-id' : null
),
}
})
vi.mock('@/services/queue', () => ({
RateLimiter: vi.fn().mockImplementation(() => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 10,
resetAt: new Date(),
}),
})),
RateLimitError: class RateLimitError extends Error {
constructor(
message: string,
public statusCode = 429
) {
super(message)
this.name = 'RateLimitError'
}
},
}))
vi.mock('@/lib/workflows/db-helpers', () => ({
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue({
blocks: {},
edges: [],
loops: {},
parallels: {},
isFromNormalizedTables: true,
}),
blockExistsInDeployment: vi.fn().mockResolvedValue(true),
}))
vi.mock('drizzle-orm/postgres-js', () => ({
drizzle: vi.fn().mockReturnValue({}),
@@ -125,9 +176,12 @@ vi.mock('drizzle-orm/postgres-js', () => ({
vi.mock('postgres', () => vi.fn().mockReturnValue({}))
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'
import { POST } from '@/app/api/webhooks/trigger/[path]/route'
describe('Webhook Trigger API Route', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
globalMockData.webhooks.length = 0
@@ -143,48 +197,6 @@ describe('Webhook Trigger API Route', () => {
workspaceId: 'test-workspace-id',
})
vi.doMock('@/lib/workspaces/utils', async () => {
const actual = await vi.importActual('@/lib/workspaces/utils')
return {
...(actual as Record<string, unknown>),
getWorkspaceBilledAccountUserId: vi
.fn()
.mockImplementation(async (workspaceId: string | null | undefined) =>
workspaceId ? 'test-user-id' : null
),
}
})
vi.doMock('@/services/queue', () => ({
RateLimiter: vi.fn().mockImplementation(() => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 10,
resetAt: new Date(),
}),
})),
RateLimitError: class RateLimitError extends Error {
constructor(
message: string,
public statusCode = 429
) {
super(message)
this.name = 'RateLimitError'
}
},
}))
vi.doMock('@/lib/workflows/db-helpers', () => ({
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue({
blocks: {},
edges: [],
loops: {},
parallels: {},
isFromNormalizedTables: true,
}),
blockExistsInDeployment: vi.fn().mockResolvedValue(true),
}))
hasProcessedMessageMock.mockResolvedValue(false)
markMessageAsProcessedMock.mockResolvedValue(true)
acquireLockMock.mockResolvedValue(true)
@@ -208,8 +220,6 @@ describe('Webhook Trigger API Route', () => {
const params = Promise.resolve({ path: 'non-existent-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(404)
@@ -239,7 +249,6 @@ describe('Webhook Trigger API Route', () => {
const req = createMockRequest('POST', { event: 'test', id: 'test-123' })
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(200)
@@ -273,7 +282,6 @@ describe('Webhook Trigger API Route', () => {
const req = createMockRequest('POST', { event: 'bearer.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(200)
@@ -305,7 +313,6 @@ describe('Webhook Trigger API Route', () => {
const req = createMockRequest('POST', { event: 'custom.header.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(200)
@@ -347,7 +354,6 @@ describe('Webhook Trigger API Route', () => {
const req = createMockRequest('POST', { event: 'case.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(200)
@@ -389,7 +395,6 @@ describe('Webhook Trigger API Route', () => {
const req = createMockRequest('POST', { event: 'custom.case.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(200)
@@ -413,7 +418,6 @@ describe('Webhook Trigger API Route', () => {
const req = createMockRequest('POST', { event: 'wrong.token.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
@@ -442,7 +446,6 @@ describe('Webhook Trigger API Route', () => {
const req = createMockRequest('POST', { event: 'wrong.custom.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
@@ -463,7 +466,6 @@ describe('Webhook Trigger API Route', () => {
const req = createMockRequest('POST', { event: 'no.auth.test' })
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
@@ -492,7 +494,6 @@ describe('Webhook Trigger API Route', () => {
const req = createMockRequest('POST', { event: 'exclusivity.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
@@ -521,7 +522,6 @@ describe('Webhook Trigger API Route', () => {
const req = createMockRequest('POST', { event: 'wrong.header.name.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
@@ -547,7 +547,6 @@ describe('Webhook Trigger API Route', () => {
const req = createMockRequest('POST', { event: 'no.token.config.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
+38
View File
@@ -0,0 +1,38 @@
import { SearchIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
export const SearchBlock: BlockConfig = {
type: 'search',
name: 'Search',
description: 'Search the web ($0.01 per search)',
longDescription: 'Search the web using the Search tool. Each search costs $0.01 per query.',
bgColor: '#3B82F6',
icon: SearchIcon,
category: 'tools',
docsLink: 'https://docs.sim.ai/tools/search',
subBlocks: [
{
id: 'query',
title: 'Search Query',
type: 'long-input',
placeholder: 'Enter your search query...',
required: true,
},
],
tools: {
access: ['search_tool'],
config: {
tool: () => 'search_tool',
},
},
inputs: {
query: { type: 'string', description: 'Search query' },
},
outputs: {
results: { type: 'json', description: 'Search results' },
query: { type: 'string', description: 'The search query' },
totalResults: { type: 'number', description: 'Total number of results' },
source: { type: 'string', description: 'Search source (exa)' },
cost: { type: 'json', description: 'Cost information ($0.01)' },
},
}
+20 -22
View File
@@ -77,12 +77,12 @@ import { RouterBlock } from '@/blocks/blocks/router'
import { S3Block } from '@/blocks/blocks/s3'
import { SalesforceBlock } from '@/blocks/blocks/salesforce'
import { ScheduleBlock } from '@/blocks/blocks/schedule'
import { SearchBlock } from '@/blocks/blocks/search'
import { SendGridBlock } from '@/blocks/blocks/sendgrid'
import { SentryBlock } from '@/blocks/blocks/sentry'
import { SerperBlock } from '@/blocks/blocks/serper'
import { SharepointBlock } from '@/blocks/blocks/sharepoint'
import { SlackBlock } from '@/blocks/blocks/slack'
import { SmtpBlock } from '@/blocks/blocks/smtp'
import { StagehandBlock } from '@/blocks/blocks/stagehand'
import { StagehandAgentBlock } from '@/blocks/blocks/stagehand_agent'
import { StartTriggerBlock } from '@/blocks/blocks/start_trigger'
@@ -95,7 +95,6 @@ import { TelegramBlock } from '@/blocks/blocks/telegram'
import { ThinkingBlock } from '@/blocks/blocks/thinking'
import { TranslateBlock } from '@/blocks/blocks/translate'
import { TrelloBlock } from '@/blocks/blocks/trello'
import { TtsBlock } from '@/blocks/blocks/tts'
import { TwilioSMSBlock } from '@/blocks/blocks/twilio'
import { TwilioVoiceBlock } from '@/blocks/blocks/twilio_voice'
import { TypeformBlock } from '@/blocks/blocks/typeform'
@@ -120,13 +119,15 @@ import type { BlockConfig } from '@/blocks/types'
export const registry: Record<string, BlockConfig> = {
agent: AgentBlock,
airtable: AirtableBlock,
api: ApiBlock,
api_trigger: ApiTriggerBlock,
apify: ApifyBlock,
apollo: ApolloBlock,
api: ApiBlock,
arxiv: ArxivBlock,
asana: AsanaBlock,
browser_use: BrowserUseBlock,
calendly: CalendlyBlock,
chat_trigger: ChatTriggerBlock,
clay: ClayBlock,
condition: ConditionBlock,
confluence: ConfluenceBlock,
@@ -134,8 +135,8 @@ export const registry: Record<string, BlockConfig> = {
elevenlabs: ElevenLabsBlock,
evaluator: EvaluatorBlock,
exa: ExaBlock,
firecrawl: FirecrawlBlock,
file: FileBlock,
firecrawl: FirecrawlBlock,
function: FunctionBlock,
generic_webhook: GenericWebhookBlock,
github: GitHubBlock,
@@ -154,6 +155,8 @@ export const registry: Record<string, BlockConfig> = {
hunter: HunterBlock,
image_generator: ImageGeneratorBlock,
incidentio: IncidentioBlock,
input_trigger: InputTriggerBlock,
intercom: IntercomBlock,
jina: JinaBlock,
jira: JiraBlock,
knowledge: KnowledgeBlock,
@@ -161,9 +164,11 @@ export const registry: Record<string, BlockConfig> = {
linkedin: LinkedInBlock,
linkup: LinkupBlock,
mailchimp: MailchimpBlock,
mailgun: MailgunBlock,
manual_trigger: ManualTriggerBlock,
mcp: McpBlock,
mem0: Mem0Block,
zep: ZepBlock,
memory: MemoryBlock,
microsoft_excel: MicrosoftExcelBlock,
microsoft_planner: MicrosoftPlannerBlock,
microsoft_teams: MicrosoftTeamsBlock,
@@ -173,44 +178,35 @@ export const registry: Record<string, BlockConfig> = {
neo4j: Neo4jBlock,
note: NoteBlock,
notion: NotionBlock,
onedrive: OneDriveBlock,
openai: OpenAIBlock,
outlook: OutlookBlock,
onedrive: OneDriveBlock,
parallel_ai: ParallelBlock,
perplexity: PerplexityBlock,
posthog: PostHogBlock,
pinecone: PineconeBlock,
pipedrive: PipedriveBlock,
postgresql: PostgreSQLBlock,
posthog: PostHogBlock,
pylon: PylonBlock,
qdrant: QdrantBlock,
resend: ResendBlock,
sendgrid: SendGridBlock,
mailgun: MailgunBlock,
smtp: SmtpBlock,
memory: MemoryBlock,
reddit: RedditBlock,
resend: ResendBlock,
response: ResponseBlock,
router: RouterBlock,
schedule: ScheduleBlock,
s3: S3Block,
salesforce: SalesforceBlock,
schedule: ScheduleBlock,
search: SearchBlock,
sendgrid: SendGridBlock,
sentry: SentryBlock,
intercom: IntercomBlock,
zendesk: ZendeskBlock,
serper: SerperBlock,
sharepoint: SharepointBlock,
slack: SlackBlock,
stagehand: StagehandBlock,
stagehand_agent: StagehandAgentBlock,
slack: SlackBlock,
starter: StarterBlock,
stt: SttBlock,
tts: TtsBlock,
start_trigger: StartTriggerBlock,
input_trigger: InputTriggerBlock,
chat_trigger: ChatTriggerBlock,
manual_trigger: ManualTriggerBlock,
api_trigger: ApiTriggerBlock,
stt: SttBlock,
stripe: StripeBlock,
supabase: SupabaseBlock,
tavily: TavilyBlock,
@@ -234,6 +230,8 @@ export const registry: Record<string, BlockConfig> = {
workflow_input: WorkflowInputBlock,
x: XBlock,
youtube: YouTubeBlock,
zep: ZepBlock,
zendesk: ZendeskBlock,
}
export const getBlock = (type: string): BlockConfig | undefined => registry[type]
@@ -318,48 +318,49 @@ describe('GenericBlockHandler', () => {
})
})
it.concurrent('should not process cost info for non-knowledge tools', async () => {
// Set up non-knowledge tool
mockBlock.config.tool = 'some_other_tool'
mockTool.id = 'some_other_tool'
it.concurrent(
'should process cost info for all tools (universal cost extraction)',
async () => {
mockBlock.config.tool = 'some_other_tool'
mockTool.id = 'some_other_tool'
mockGetTool.mockImplementation((toolId) => {
if (toolId === 'some_other_tool') {
return mockTool
mockGetTool.mockImplementation((toolId) => {
if (toolId === 'some_other_tool') {
return mockTool
}
return undefined
})
const inputs = { param: 'value' }
const mockToolResponse = {
success: true,
output: {
result: 'success',
cost: {
input: 0.001,
output: 0.002,
total: 0.003,
tokens: { prompt: 100, completion: 50, total: 150 },
model: 'some-model',
},
},
}
return undefined
})
const inputs = { param: 'value' }
const mockToolResponse = {
success: true,
output: {
mockExecuteTool.mockResolvedValue(mockToolResponse)
const result = await handler.execute(mockContext, mockBlock, inputs)
expect(result).toEqual({
result: 'success',
cost: {
input: 0.001,
output: 0.002,
total: 0.003,
tokens: { prompt: 100, completion: 50, total: 150 },
model: 'some-model',
},
},
}
mockExecuteTool.mockResolvedValue(mockToolResponse)
const result = await handler.execute(mockContext, mockBlock, inputs)
// Should return original output without cost transformation
expect(result).toEqual({
result: 'success',
cost: {
input: 0.001,
output: 0.002,
total: 0.003,
tokens: { prompt: 100, completion: 50, total: 150 },
model: 'some-model',
},
})
})
})
}
)
})
})
@@ -104,7 +104,7 @@ export class GenericBlockHandler implements BlockHandler {
const output = result.output
let cost = null
if (block.config.tool?.startsWith('knowledge_') && output?.cost) {
if (output?.cost) {
cost = output.cost
}
+5
View File
@@ -20,6 +20,11 @@ export const DEFAULT_ENTERPRISE_TIER_COST_LIMIT = 200
*/
export const BASE_EXECUTION_CHARGE = 0.001
/**
* Fixed cost for search tool invocation (in dollars)
*/
export const SEARCH_TOOL_COST = 0.01
/**
* Default threshold (in dollars) for incremental overage billing
* When unbilled overage reaches this amount, an invoice item is created
+49 -43
View File
@@ -9,47 +9,51 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const mockDb = {
select: vi.fn(),
insert: vi.fn(),
delete: vi.fn(),
transaction: vi.fn(),
}
const { mockDb, mockWorkflowBlocks, mockWorkflowEdges, mockWorkflowSubflows } = vi.hoisted(() => {
const mockDb = {
select: vi.fn(),
insert: vi.fn(),
delete: vi.fn(),
transaction: vi.fn(),
}
const mockWorkflowBlocks = {
workflowId: 'workflowId',
id: 'id',
type: 'type',
name: 'name',
positionX: 'positionX',
positionY: 'positionY',
enabled: 'enabled',
horizontalHandles: 'horizontalHandles',
height: 'height',
subBlocks: 'subBlocks',
outputs: 'outputs',
data: 'data',
parentId: 'parentId',
extent: 'extent',
}
const mockWorkflowBlocks = {
workflowId: 'workflowId',
id: 'id',
type: 'type',
name: 'name',
positionX: 'positionX',
positionY: 'positionY',
enabled: 'enabled',
horizontalHandles: 'horizontalHandles',
height: 'height',
subBlocks: 'subBlocks',
outputs: 'outputs',
data: 'data',
parentId: 'parentId',
extent: 'extent',
}
const mockWorkflowEdges = {
workflowId: 'workflowId',
id: 'id',
sourceBlockId: 'sourceBlockId',
targetBlockId: 'targetBlockId',
sourceHandle: 'sourceHandle',
targetHandle: 'targetHandle',
}
const mockWorkflowEdges = {
workflowId: 'workflowId',
id: 'id',
sourceBlockId: 'sourceBlockId',
targetBlockId: 'targetBlockId',
sourceHandle: 'sourceHandle',
targetHandle: 'targetHandle',
}
const mockWorkflowSubflows = {
workflowId: 'workflowId',
id: 'id',
type: 'type',
config: 'config',
}
const mockWorkflowSubflows = {
workflowId: 'workflowId',
id: 'id',
type: 'type',
config: 'config',
}
vi.doMock('@sim/db', () => ({
return { mockDb, mockWorkflowBlocks, mockWorkflowEdges, mockWorkflowSubflows }
})
vi.mock('@sim/db', () => ({
db: mockDb,
workflowBlocks: mockWorkflowBlocks,
workflowEdges: mockWorkflowEdges,
@@ -64,9 +68,11 @@ vi.doMock('@sim/db', () => ({
createdBy: 'createdBy',
deployedBy: 'deployedBy',
},
workflow: {},
webhook: {},
}))
vi.doMock('drizzle-orm', () => ({
vi.mock('drizzle-orm', () => ({
eq: vi.fn((field, value) => ({ field, value, type: 'eq' })),
and: vi.fn((...conditions) => ({ type: 'and', conditions })),
desc: vi.fn((field) => ({ field, type: 'desc' })),
@@ -78,7 +84,7 @@ vi.doMock('drizzle-orm', () => ({
})),
}))
vi.doMock('@/lib/logs/console/logger', () => ({
vi.mock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
@@ -87,6 +93,8 @@ vi.doMock('@/lib/logs/console/logger', () => ({
})),
}))
import * as dbHelpers from '@/lib/workflows/db-helpers'
const mockWorkflowId = 'test-workflow-123'
const mockBlocksFromDb = [
@@ -306,11 +314,8 @@ const mockWorkflowState: WorkflowState = {
}
describe('Database Helpers', () => {
let dbHelpers: typeof import('@/lib/workflows/db-helpers')
beforeEach(async () => {
beforeEach(() => {
vi.clearAllMocks()
dbHelpers = await import('@/lib/workflows/db-helpers')
})
afterEach(() => {
@@ -341,6 +346,7 @@ describe('Database Helpers', () => {
}))
const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId)
expect(result).toBeDefined()
expect(result?.isFromNormalizedTables).toBe(true)
expect(result?.blocks).toBeDefined()
+10 -2
View File
@@ -117,7 +117,13 @@ import {
exaSearchTool,
} from '@/tools/exa'
import { fileParseTool } from '@/tools/file'
import { crawlTool, extractTool, mapTool, scrapeTool, searchTool } from '@/tools/firecrawl'
import {
crawlTool,
extractTool,
searchTool as firecrawlSearchTool,
mapTool,
scrapeTool,
} from '@/tools/firecrawl'
import { functionExecuteTool } from '@/tools/function'
import {
githubAddAssigneesTool,
@@ -768,6 +774,7 @@ import {
salesforceUpdateOpportunityTool,
salesforceUpdateTaskTool,
} from '@/tools/salesforce'
import { searchTool } from '@/tools/search'
import {
sendGridAddContactsToListTool,
sendGridAddContactTool,
@@ -1047,7 +1054,7 @@ export const tools: Record<string, ToolConfig> = {
vision_tool: visionTool,
file_parser: fileParseTool,
firecrawl_scrape: scrapeTool,
firecrawl_search: searchTool,
firecrawl_search: firecrawlSearchTool,
firecrawl_crawl: crawlTool,
firecrawl_map: mapTool,
firecrawl_extract: extractTool,
@@ -1455,6 +1462,7 @@ export const tools: Record<string, ToolConfig> = {
knowledge_search: knowledgeSearchTool,
knowledge_upload_chunk: knowledgeUploadChunkTool,
knowledge_create_document: knowledgeCreateDocumentTool,
search_tool: searchTool,
elevenlabs_tts: elevenLabsTtsTool,
stt_whisper: whisperSttTool,
stt_deepgram: deepgramSttTool,
+2
View File
@@ -0,0 +1,2 @@
export { searchTool } from './tool'
export type { SearchParams, SearchResponse } from './types'
+41
View File
@@ -0,0 +1,41 @@
import type { ToolConfig } from '@/tools/types'
import type { SearchParams, SearchResponse } from './types'
export const searchTool: ToolConfig<SearchParams, SearchResponse> = {
id: 'search_tool',
name: 'Web Search',
description:
'Search the web. Returns the most relevant web results, including title, link, snippet, and date for each result.',
version: '1.0.0',
params: {
query: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The search query',
},
},
request: {
url: () => '/api/tools/search',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
query: params.query,
}),
},
transformResponse: async (response) => {
if (!response.ok) {
throw new Error(`Search failed: ${response.statusText}`)
}
const data = await response.json()
return {
success: true,
output: data,
}
},
}
+37
View File
@@ -0,0 +1,37 @@
import type { ToolResponse } from '@/tools/types'
export interface SearchParams {
query: string
}
export interface SearchResponse extends ToolResponse {
output: {
results: Array<{
title: string
link: string
snippet: string
date?: string
position: number
}>
query: string
totalResults: number
source: 'exa'
cost: {
input: number
output: number
total: number
tokens: {
prompt: number
completion: number
total: number
}
model: string
pricing?: {
input: number
cachedInput: number
output: number
updatedAt: string
}
}
}
}