Merge pull request #1740 from escapade-mckv/people-search-tool

People search tool
This commit is contained in:
Bobbie 2025-09-27 14:08:32 +05:30 committed by GitHub
commit e1e94292d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1795 additions and 10 deletions

View File

@ -220,6 +220,7 @@ def _get_default_agentpress_tools() -> Dict[str, bool]:
# "sb_web_dev_tool": True,
"browser_tool": True,
"data_providers_tool": True,
"people_search_tool": False,
"agent_config_tool": True,
"mcp_search_tool": True,
"credential_profile_tool": True,

View File

@ -42,6 +42,8 @@ from core.tools.sb_sheets_tool import SandboxSheetsTool
# from core.tools.sb_web_dev_tool import SandboxWebDevTool # DEACTIVATED
from core.tools.sb_upload_file_tool import SandboxUploadFileTool
from core.tools.sb_docs_tool import SandboxDocsTool
from core.tools.people_search_tool import PeopleSearchTool
from core.tools.company_search_tool import CompanySearchTool
from core.ai_models.manager import model_manager
load_dotenv()
@ -141,7 +143,6 @@ class ToolManager:
# logger.debug(f"Registered {tool_name} (all methods)")
def _register_utility_tools(self, disabled_tools: List[str]):
"""Register utility and data provider tools."""
if config.RAPID_API_KEY and 'data_providers_tool' not in disabled_tools:
# Check for granular method control
enabled_methods = self._get_enabled_methods_for_tool('data_providers_tool')
@ -153,6 +154,26 @@ class ToolManager:
# Register all methods (backward compatibility)
self.thread_manager.add_tool(DataProvidersTool)
# logger.debug("Registered data_providers_tool (all methods)")
# Register search tools if EXA API key is available
if config.EXA_API_KEY:
if 'people_search_tool' not in disabled_tools:
enabled_methods = self._get_enabled_methods_for_tool('people_search_tool')
if enabled_methods is not None:
self.thread_manager.add_tool(PeopleSearchTool, function_names=enabled_methods, thread_manager=self.thread_manager)
logger.debug(f"Registered people_search_tool with methods: {enabled_methods}")
else:
self.thread_manager.add_tool(PeopleSearchTool, thread_manager=self.thread_manager)
logger.debug("Registered people_search_tool (all methods)")
if 'company_search_tool' not in disabled_tools:
enabled_methods = self._get_enabled_methods_for_tool('company_search_tool')
if enabled_methods is not None:
self.thread_manager.add_tool(CompanySearchTool, function_names=enabled_methods, thread_manager=self.thread_manager)
logger.debug(f"Registered company_search_tool with methods: {enabled_methods}")
else:
self.thread_manager.add_tool(CompanySearchTool, thread_manager=self.thread_manager)
logger.debug("Registered company_search_tool (all methods)")
def _register_agent_builder_tools(self, agent_id: str, disabled_tools: List[str]):
"""Register agent builder tools."""
@ -593,6 +614,22 @@ class AgentRunner:
else:
logger.debug("Not a Suna agent, skipping Suna-specific tool registration")
def _get_enabled_methods_for_tool(self, tool_name: str) -> Optional[List[str]]:
if not self.config.agent_config or 'agentpress_tools' not in self.config.agent_config:
return None
from core.utils.tool_groups import get_enabled_methods_for_tool
from core.utils.tool_migration import migrate_legacy_tool_config
raw_tools = self.config.agent_config['agentpress_tools']
if not isinstance(raw_tools, dict):
return None
migrated_tools = migrate_legacy_tool_config(raw_tools)
return get_enabled_methods_for_tool(tool_name, migrated_tools)
def _register_suna_specific_tools(self, disabled_tools: List[str]):
if 'agent_creation_tool' not in disabled_tools:
from core.tools.agent_creation_tool import AgentCreationTool
@ -644,7 +681,7 @@ class AgentRunner:
'sb_shell_tool', 'sb_files_tool', 'sb_deploy_tool', 'sb_expose_tool',
'web_search_tool', 'image_search_tool', 'sb_vision_tool', 'sb_presentation_tool', 'sb_image_edit_tool',
'sb_sheets_tool', 'sb_web_dev_tool', 'data_providers_tool', 'browser_tool',
'agent_config_tool', 'mcp_search_tool', 'credential_profile_tool',
'people_search_tool', 'company_search_tool', 'agent_config_tool', 'mcp_search_tool', 'credential_profile_tool',
'workflow_tool', 'trigger_tool'
]

View File

@ -32,6 +32,10 @@ SUNA_CONFIG = {
"sb_presentation_tool": True,
"sb_sheets_tool": True,
"sb_kb_tool": True,
# search tools
"people_search_tool": True,
"company_search_tool": True,
# Browser automation (both variants)
"browser_tool": True,

View File

@ -0,0 +1,304 @@
from typing import Optional
import asyncio
import structlog
import json
from decimal import Decimal
from exa_py import Exa
from exa_py.websets.types import CreateWebsetParameters, CreateEnrichmentParameters
from core.agentpress.tool import Tool, ToolResult, openapi_schema, usage_example
from core.utils.config import config, EnvMode
from core.utils.logger import logger
from core.agentpress.thread_manager import ThreadManager
from core.billing.credit_manager import CreditManager
from core.billing.config import TOKEN_PRICE_MULTIPLIER
from core.services.supabase import DBConnection
class CompanySearchTool(Tool):
def __init__(self, thread_manager: ThreadManager):
super().__init__()
self.thread_manager = thread_manager
self.api_key = config.EXA_API_KEY
self.db = DBConnection()
self.credit_manager = CreditManager()
self.exa_client = None
if self.api_key:
self.exa_client = Exa(self.api_key)
logger.info("Company Search Tool initialized. Note: This requires an Exa Pro plan for Websets API access.")
else:
logger.warning("EXA_API_KEY not configured - Company Search Tool will not be available")
async def _get_current_thread_and_user(self) -> tuple[Optional[str], Optional[str]]:
try:
context_vars = structlog.contextvars.get_contextvars()
thread_id = context_vars.get('thread_id')
if not thread_id:
logger.warning("No thread_id in execution context")
return None, None
client = await self.db.client
thread = await client.from_('threads').select('account_id').eq('thread_id', thread_id).single().execute()
if thread.data:
return thread_id, thread.data.get('account_id')
except Exception as e:
logger.error(f"Failed to get thread context: {e}")
return None, None
async def _deduct_credits(self, user_id: str, num_results: int, thread_id: Optional[str] = None) -> bool:
base_cost = Decimal('0.45')
total_cost = base_cost * TOKEN_PRICE_MULTIPLIER
try:
result = await self.credit_manager.use_credits(
account_id=user_id,
amount=total_cost,
description=f"Company search: {num_results} results",
thread_id=thread_id
)
if result.get('success'):
logger.info(f"Deducted ${total_cost:.2f} for company search ({num_results} results)")
return True
else:
logger.warning(f"Failed to deduct credits: {result.get('error')}")
return False
except Exception as e:
logger.error(f"Error deducting credits: {e}")
return False
@openapi_schema({
"type": "function",
"function": {
"name": "company_search",
"description": "Search for companies using natural language queries and enrich with company profiles. IMPORTANT: Requires Exa Pro plan and costs $0.54 per search (10 results).",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Natural language search query describing the companies you want to find. Examples: 'AI startups in San Francisco with recent funding', 'Fortune 500 companies in healthcare sector', 'B2B SaaS companies with over 100 employees in New York'"
},
"enrichment_description": {
"type": "string",
"description": "What specific information to find about each company. Default: 'Company website, funding information, and key details'",
"default": "Company website, funding information, and key details"
}
},
"required": ["query"]
}
}
})
@usage_example('''
<function_calls>
<invoke name="company_search">
<parameter name="query">B2B SaaS companies in Silicon Valley with over $10M funding</parameter>
</invoke>
</function_calls>
<function_calls>
<invoke name="company_search">
<parameter name="query">Healthcare technology startups in Boston</parameter>
<parameter name="enrichment_description">Company website, recent news, and leadership team</parameter>
</invoke>
</function_calls>
''')
async def company_search(
self,
query: str,
enrichment_description: str = "Company website, funding information, and key details"
) -> ToolResult:
if not self.exa_client:
return self.fail_response(
"Company Search is not available. EXA_API_KEY is not configured. "
"Please contact your administrator to enable this feature."
)
if not query:
return self.fail_response("Search query is required.")
thread_id, user_id = await self._get_current_thread_and_user()
if config.ENV_MODE != EnvMode.LOCAL and (not thread_id or not user_id):
return self.fail_response(
"No active session context for billing. This tool requires an active agent session."
)
try:
logger.info(f"Creating Exa webset for: '{query}' with 10 results")
enrichment_config = CreateEnrichmentParameters(
description=enrichment_description,
format="text"
)
webset_params = CreateWebsetParameters(
search={
"query": query,
"count": 10
},
enrichments=[enrichment_config]
)
try:
webset = await asyncio.to_thread(
self.exa_client.websets.create,
params=webset_params
)
logger.info(f"Webset created with ID: {webset.id}")
except Exception as create_error:
logger.error(f"Failed to create webset - Error type: {type(create_error).__name__}")
try:
error_str = str(create_error)
logger.error(f"Failed to create webset - Error message: {error_str}")
except:
error_str = "Unknown error"
logger.error(f"Failed to create webset - Could not convert error to string")
if "401" in error_str:
return self.fail_response(
"Authentication failed with Exa API. Please check your API key and Pro plan status."
)
elif "400" in error_str:
return self.fail_response(
"Invalid request to Exa API. Please check your query format."
)
else:
return self.fail_response(
"Failed to create webset. Please try again."
)
logger.info(f"Waiting for webset {webset.id} to complete processing...")
try:
webset = await asyncio.to_thread(
self.exa_client.websets.wait_until_idle,
webset.id
)
logger.info(f"Webset {webset.id} processing complete")
except Exception as wait_error:
logger.error(f"Error waiting for webset: {type(wait_error).__name__}: {repr(wait_error)}")
return self.fail_response("Failed while waiting for search results. Please try again.")
logger.info(f"Retrieving items from webset {webset.id}...")
try:
items = await asyncio.to_thread(
self.exa_client.websets.items.list,
webset_id=webset.id
)
logger.info(f"Retrieved items from webset")
except Exception as items_error:
logger.error(f"Error retrieving items: {type(items_error).__name__}: {repr(items_error)}")
return self.fail_response("Failed to retrieve search results. Please try again.")
results = items.data if items else []
logger.info(f"Got {len(results)} results from webset")
formatted_results = []
for idx, item in enumerate(results[:10], 1):
if hasattr(item, 'model_dump'):
item_dict = item.model_dump()
elif isinstance(item, dict):
item_dict = item
else:
item_dict = vars(item) if hasattr(item, '__dict__') else {}
properties = item_dict.get('properties', {})
company_info = properties.get('company', {})
evaluations_text = ""
evaluations = item_dict.get('evaluations', [])
if evaluations:
eval_items = []
for eval_item in evaluations:
if isinstance(eval_item, dict):
criterion = eval_item.get('criterion', '')
satisfied = eval_item.get('satisfied', '')
if criterion:
eval_items.append(f"{criterion}: {satisfied}")
evaluations_text = " | ".join(eval_items)
enrichment_text = ""
if 'enrichments' in item_dict and item_dict['enrichments']:
enrichments = item_dict['enrichments']
if isinstance(enrichments, list) and len(enrichments) > 0:
enrichment = enrichments[0]
if isinstance(enrichment, dict):
enrich_result = enrichment.get('result')
if enrich_result is not None:
if isinstance(enrich_result, list) and enrich_result:
enrichment_text = str(enrich_result[0]) if enrich_result[0] else ""
elif isinstance(enrich_result, str):
enrichment_text = enrich_result
else:
enrichment_text = str(enrich_result) if enrich_result else ""
logo_url = company_info.get('logo_url', '')
if logo_url is None:
logo_url = ''
result_entry = {
"rank": idx,
"id": item_dict.get('id', ''),
"webset_id": item_dict.get('webset_id', ''),
"source": str(item_dict.get('source', '')),
"source_id": item_dict.get('source_id', ''),
"url": properties.get('url', ''),
"type": properties.get('type', ''),
"description": properties.get('description', ''),
"company_name": company_info.get('name', ''),
"company_location": company_info.get('location', ''),
"company_industry": company_info.get('industry', ''),
"company_logo_url": str(logo_url) if logo_url else '',
"evaluations": evaluations_text,
"enrichment_data": enrichment_text,
"created_at": str(item_dict.get('created_at', '')),
"updated_at": str(item_dict.get('updated_at', ''))
}
formatted_results.append(result_entry)
base_cost = Decimal('0.45')
total_cost = base_cost * TOKEN_PRICE_MULTIPLIER
if config.ENV_MODE == EnvMode.LOCAL:
logger.info("Running in LOCAL mode - skipping billing for company search")
cost_deducted_str = f"${total_cost:.2f} (LOCAL - not charged)"
else:
credits_deducted = await self._deduct_credits(user_id, len(formatted_results), thread_id)
if not credits_deducted:
return self.fail_response(
"Insufficient credits for company search. "
f"This search costs ${total_cost:.2f} ({len(formatted_results)} results). "
"Please add credits to continue."
)
cost_deducted_str = f"${total_cost:.2f}"
output = {
"query": query,
"total_results": len(formatted_results),
"cost_deducted": cost_deducted_str,
"results": formatted_results,
"enrichment_type": enrichment_description
}
logger.info(f"Successfully completed company search with {len(formatted_results)} results")
try:
json_output = json.dumps(output, indent=2, default=str)
return self.success_response(json_output)
except Exception as json_error:
logger.error(f"Failed to serialize output: {json_error}")
summary = f"Found {len(formatted_results)} results for query: {query}"
if formatted_results:
summary += f"\n\nTop result:\nName: {formatted_results[0].get('company_name', 'Unknown')}\nIndustry: {formatted_results[0].get('company_industry', 'Unknown')}\nLocation: {formatted_results[0].get('company_location', 'Unknown')}"
return self.success_response(summary)
except asyncio.TimeoutError:
return self.fail_response("Search timed out. Please try again with a simpler query.")
except Exception as e:
logger.error(f"Company search failed: {repr(e)}", exc_info=True)
return self.fail_response("An error occurred during the search. Please try again.")

View File

@ -0,0 +1,304 @@
from typing import Optional
import asyncio
import structlog
import json
from decimal import Decimal
from exa_py import Exa
from exa_py.websets.types import CreateWebsetParameters, CreateEnrichmentParameters
from core.agentpress.tool import Tool, ToolResult, openapi_schema, usage_example
from core.utils.config import config, EnvMode
from core.utils.logger import logger
from core.agentpress.thread_manager import ThreadManager
from core.billing.credit_manager import CreditManager
from core.billing.config import TOKEN_PRICE_MULTIPLIER
from core.services.supabase import DBConnection
class PeopleSearchTool(Tool):
def __init__(self, thread_manager: ThreadManager):
super().__init__()
self.thread_manager = thread_manager
self.api_key = config.EXA_API_KEY
self.db = DBConnection()
self.credit_manager = CreditManager()
self.exa_client = None
if self.api_key:
self.exa_client = Exa(self.api_key)
logger.info("People Search Tool initialized. Note: This requires an Exa Pro plan for Websets API access.")
else:
logger.warning("EXA_API_KEY not configured - People Search Tool will not be available")
async def _get_current_thread_and_user(self) -> tuple[Optional[str], Optional[str]]:
try:
context_vars = structlog.contextvars.get_contextvars()
thread_id = context_vars.get('thread_id')
if not thread_id:
logger.warning("No thread_id in execution context")
return None, None
client = await self.db.client
thread = await client.from_('threads').select('account_id').eq('thread_id', thread_id).single().execute()
if thread.data:
return thread_id, thread.data.get('account_id')
except Exception as e:
logger.error(f"Failed to get thread context: {e}")
return None, None
async def _deduct_credits(self, user_id: str, num_results: int, thread_id: Optional[str] = None) -> bool:
base_cost = Decimal('0.45')
total_cost = base_cost * TOKEN_PRICE_MULTIPLIER
try:
result = await self.credit_manager.use_credits(
account_id=user_id,
amount=total_cost,
description=f"People search: {num_results} results",
thread_id=thread_id
)
if result.get('success'):
logger.info(f"Deducted ${total_cost:.2f} for people search ({num_results} results)")
return True
else:
logger.warning(f"Failed to deduct credits: {result.get('error')}")
return False
except Exception as e:
logger.error(f"Error deducting credits: {e}")
return False
@openapi_schema({
"type": "function",
"function": {
"name": "people_search",
"description": "Search for people using natural language queries and enrich with LinkedIn profiles. IMPORTANT: Requires Exa Pro plan and costs $0.54 per search (10 results).",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Natural language search query describing the people you want to find. Examples: 'CTOs at AI startups in San Francisco', 'Senior Python developers with machine learning experience at Google', 'Marketing managers at Fortune 500 companies in New York'"
},
"enrichment_description": {
"type": "string",
"description": "What specific information to find about each person. Default: 'LinkedIn profile URL'",
"default": "LinkedIn profile URL"
}
},
"required": ["query"]
}
}
})
@usage_example('''
<function_calls>
<invoke name="people_search">
<parameter name="query">Chief Technology Officers at B2B SaaS companies in Silicon Valley</parameter>
</invoke>
</function_calls>
<function_calls>
<invoke name="people_search">
<parameter name="query">Senior Python developers with AI experience at FAANG companies</parameter>
<parameter name="enrichment_description">LinkedIn profile and current job title</parameter>
</invoke>
</function_calls>
''')
async def people_search(
self,
query: str,
enrichment_description: str = "LinkedIn profile URL"
) -> ToolResult:
if not self.exa_client:
return self.fail_response(
"People Search is not available. EXA_API_KEY is not configured. "
"Please contact your administrator to enable this feature."
)
if not query:
return self.fail_response("Search query is required.")
thread_id, user_id = await self._get_current_thread_and_user()
if config.ENV_MODE != EnvMode.LOCAL and (not thread_id or not user_id):
return self.fail_response(
"No active session context for billing. This tool requires an active agent session."
)
try:
logger.info(f"Creating Exa webset for: '{query}' with 10 results")
enrichment_config = CreateEnrichmentParameters(
description=enrichment_description,
format="text"
)
webset_params = CreateWebsetParameters(
search={
"query": query,
"count": 10
},
enrichments=[enrichment_config]
)
try:
webset = await asyncio.to_thread(
self.exa_client.websets.create,
params=webset_params
)
logger.info(f"Webset created with ID: {webset.id}")
except Exception as create_error:
logger.error(f"Failed to create webset - Error type: {type(create_error).__name__}")
try:
error_str = str(create_error)
logger.error(f"Failed to create webset - Error message: {error_str}")
except:
error_str = "Unknown error"
logger.error(f"Failed to create webset - Could not convert error to string")
if "401" in error_str:
return self.fail_response(
"Authentication failed with Exa API. Please check your API key and Pro plan status."
)
elif "400" in error_str:
return self.fail_response(
"Invalid request to Exa API. Please check your query format."
)
else:
return self.fail_response(
"Failed to create webset. Please try again."
)
logger.info(f"Waiting for webset {webset.id} to complete processing...")
try:
webset = await asyncio.to_thread(
self.exa_client.websets.wait_until_idle,
webset.id
)
logger.info(f"Webset {webset.id} processing complete")
except Exception as wait_error:
logger.error(f"Error waiting for webset: {type(wait_error).__name__}: {repr(wait_error)}")
return self.fail_response("Failed while waiting for search results. Please try again.")
logger.info(f"Retrieving items from webset {webset.id}...")
try:
items = await asyncio.to_thread(
self.exa_client.websets.items.list,
webset_id=webset.id
)
logger.info(f"Retrieved items from webset")
except Exception as items_error:
logger.error(f"Error retrieving items: {type(items_error).__name__}: {repr(items_error)}")
return self.fail_response("Failed to retrieve search results. Please try again.")
results = items.data if items else []
logger.info(f"Got {len(results)} results from webset")
formatted_results = []
for idx, item in enumerate(results[:10], 1):
if hasattr(item, 'model_dump'):
item_dict = item.model_dump()
elif isinstance(item, dict):
item_dict = item
else:
item_dict = vars(item) if hasattr(item, '__dict__') else {}
properties = item_dict.get('properties', {})
person_info = properties.get('person', {})
evaluations_text = ""
evaluations = item_dict.get('evaluations', [])
if evaluations:
eval_items = []
for eval_item in evaluations:
if isinstance(eval_item, dict):
criterion = eval_item.get('criterion', '')
satisfied = eval_item.get('satisfied', '')
if criterion:
eval_items.append(f"{criterion}: {satisfied}")
evaluations_text = " | ".join(eval_items)
enrichment_text = ""
if 'enrichments' in item_dict and item_dict['enrichments']:
enrichments = item_dict['enrichments']
if isinstance(enrichments, list) and len(enrichments) > 0:
enrichment = enrichments[0]
if isinstance(enrichment, dict):
enrich_result = enrichment.get('result')
if enrich_result is not None:
if isinstance(enrich_result, list) and enrich_result:
enrichment_text = str(enrich_result[0]) if enrich_result[0] else ""
elif isinstance(enrich_result, str):
enrichment_text = enrich_result
else:
enrichment_text = str(enrich_result) if enrich_result else ""
picture_url = person_info.get('picture_url', '')
if picture_url is None:
picture_url = ''
result_entry = {
"rank": idx,
"id": item_dict.get('id', ''),
"webset_id": item_dict.get('webset_id', ''),
"source": str(item_dict.get('source', '')),
"source_id": item_dict.get('source_id', ''),
"url": properties.get('url', ''),
"type": properties.get('type', ''),
"description": properties.get('description', ''),
"person_name": person_info.get('name', ''),
"person_location": person_info.get('location', ''),
"person_position": person_info.get('position', ''),
"person_picture_url": str(picture_url) if picture_url else '',
"evaluations": evaluations_text,
"enrichment_data": enrichment_text,
"created_at": str(item_dict.get('created_at', '')),
"updated_at": str(item_dict.get('updated_at', ''))
}
formatted_results.append(result_entry)
base_cost = Decimal('0.45')
total_cost = base_cost * TOKEN_PRICE_MULTIPLIER
if config.ENV_MODE == EnvMode.LOCAL:
logger.info("Running in LOCAL mode - skipping billing for people search")
cost_deducted_str = f"${total_cost:.2f} (LOCAL - not charged)"
else:
credits_deducted = await self._deduct_credits(user_id, len(formatted_results), thread_id)
if not credits_deducted:
return self.fail_response(
"Insufficient credits for people search. "
f"This search costs ${total_cost:.2f} ({len(formatted_results)} results). "
"Please add credits to continue."
)
cost_deducted_str = f"${total_cost:.2f}"
output = {
"query": query,
"total_results": len(formatted_results),
"cost_deducted": cost_deducted_str,
"results": formatted_results,
"enrichment_type": enrichment_description
}
logger.info(f"Successfully completed people search with {len(formatted_results)} results")
try:
json_output = json.dumps(output, indent=2, default=str)
return self.success_response(json_output)
except Exception as json_error:
logger.error(f"Failed to serialize output: {json_error}")
summary = f"Found {len(formatted_results)} results for query: {query}"
if formatted_results:
summary += f"\n\nTop result:\nName: {formatted_results[0].get('person_name', 'Unknown')}\nPosition: {formatted_results[0].get('person_position', 'Unknown')}\nLocation: {formatted_results[0].get('person_location', 'Unknown')}"
return self.success_response(summary)
except asyncio.TimeoutError:
return self.fail_response("Search timed out. Please try again with a simpler query.")
except Exception as e:
logger.error(f"People search failed: {repr(e)}", exc_info=True)
return self.fail_response("An error occurred during the search. Please try again.")

View File

@ -582,8 +582,9 @@ class WorkflowExecutor:
'sb_deploy_tool': ['deploy'],
'sb_expose_tool': ['expose_port'],
'web_search_tool': ['web_search'],
'data_providers_tool': ['get_data_provider_endpoints', 'execute_data_provider_call'],
'people_search_tool': ['people_search'],
'image_search_tool': ['image_search'],
'data_providers_tool': ['get_data_provider_endpoints', 'execute_data_provider_call']
}
for tool_key, tool_names in tool_mapping.items():

View File

@ -299,6 +299,7 @@ class Configuration:
CLOUDFLARE_API_TOKEN: Optional[str] = None
FIRECRAWL_API_KEY: str
FIRECRAWL_URL: Optional[str] = "https://api.firecrawl.dev"
EXA_API_KEY: Optional[str] = None
# Stripe configuration
STRIPE_SECRET_KEY: Optional[str] = None

View File

@ -817,6 +817,36 @@ TOOL_GROUPS: Dict[str, ToolGroup] = {
]
),
"people_search_tool": ToolGroup(
name="people_search_tool",
display_name="People Search",
description="Search for people using LinkedIn",
tool_class="PeopleSearchTool",
methods=[
ToolMethod(
name="people_search",
display_name="People Search",
description="Search for people using LinkedIn",
enabled=True
),
]
),
"company_search_tool": ToolGroup(
name="company_search_tool",
display_name="Company Search",
description="Search for companies using natural language queries",
tool_class="CompanySearchTool",
methods=[
ToolMethod(
name="company_search",
display_name="Company Search",
description="Search for companies using natural language queries",
enabled=True
),
]
),
"sb_web_dev_tool": ToolGroup(
name="sb_web_dev_tool",
display_name="Web Development",

View File

@ -43,7 +43,6 @@ export const TOOL_GROUPS: Record<string, ToolGroup> = {
],
},
// === SHELL OPERATIONS ===
sb_shell_tool: {
name: 'sb_shell_tool',
displayName: 'Shell Operations',
@ -80,7 +79,6 @@ export const TOOL_GROUPS: Record<string, ToolGroup> = {
],
},
// === WEB SEARCH ===
web_search_tool: {
name: 'web_search_tool',
displayName: 'Web Search',
@ -105,7 +103,24 @@ export const TOOL_GROUPS: Record<string, ToolGroup> = {
],
},
// === VISION & IMAGE ANALYSIS ===
people_search_tool: {
name: 'people_search_tool',
displayName: 'Web Search',
description: 'Search for people using LinkedIn',
icon: 'Globe',
color: 'bg-indigo-100 dark:bg-indigo-800/50',
toolClass: 'PeopleSearchTool',
enabled: true,
methods: [
{
name: 'people_search',
displayName: 'People Search',
description: 'Search for people using LinkedIn',
enabled: true,
},
],
},
sb_vision_tool: {
name: 'sb_vision_tool',
displayName: 'Vision & Image Analysis',
@ -130,7 +145,6 @@ export const TOOL_GROUPS: Record<string, ToolGroup> = {
],
},
// === IMAGE EDITING ===
sb_image_edit_tool: {
name: 'sb_image_edit_tool',
displayName: 'Image Editing',
@ -149,7 +163,6 @@ export const TOOL_GROUPS: Record<string, ToolGroup> = {
],
},
// === BROWSER AUTOMATION ===
browser_tool: {
name: 'browser_tool',
displayName: 'Browser Automation',

View File

@ -0,0 +1,279 @@
import React from 'react';
import {
Building2,
CheckCircle,
AlertTriangle,
ExternalLink,
MapPin,
Globe,
Award,
Info,
Building
} from 'lucide-react';
import { ToolViewProps } from '../types';
import { formatTimestamp, getToolTitle } from '../utils';
import { truncateString } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { LoadingState } from '../shared/LoadingState';
import { extractCompanySearchData } from './_utils';
import { cn } from '@/lib/utils';
export function CompanySearchToolView({
name = 'company-search',
assistantContent,
toolContent,
assistantTimestamp,
toolTimestamp,
isSuccess = true,
isStreaming = false,
}: ToolViewProps) {
const {
query,
total_results,
cost_deducted,
results,
actualIsSuccess,
actualToolTimestamp,
actualAssistantTimestamp
} = extractCompanySearchData(
assistantContent,
toolContent,
isSuccess,
toolTimestamp,
assistantTimestamp
);
const toolTitle = getToolTitle(name);
const parseEvaluations = (evaluationsText: string) => {
if (!evaluationsText) return [];
return evaluationsText.split(' | ').map(item => {
const [criterion, satisfied] = item.split(': ');
return { criterion, satisfied };
});
};
return (
<Card className="gap-0 flex border shadow-none border-t border-b-0 border-x-0 p-0 rounded-none flex-col h-full overflow-hidden bg-card">
<CardHeader className="h-14 bg-zinc-50/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b p-2 px-4 space-y-2">
<div className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<div className="relative p-2 rounded-xl bg-blue-500/20 border border-blue-500/20">
<Building2 className="w-5 h-5 text-blue-500 dark:text-blue-400" />
</div>
<div>
<CardTitle className="text-base font-medium text-zinc-900 dark:text-zinc-100">
{toolTitle}
</CardTitle>
</div>
</div>
{!isStreaming && (
<div className="flex items-center gap-2">
{cost_deducted && (
<Badge variant="outline" className="text-xs font-normal text-orange-600 dark:text-orange-400">
{cost_deducted}
</Badge>
)}
<Badge
variant="secondary"
className={
actualIsSuccess
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
: "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300"
}
>
{actualIsSuccess ? (
<CheckCircle className="h-3.5 w-3.5" />
) : (
<AlertTriangle className="h-3.5 w-3.5" />
)}
{actualIsSuccess ? 'Search completed' : 'Search failed'}
</Badge>
</div>
)}
</div>
</CardHeader>
<CardContent className="p-0 h-full flex-1 overflow-hidden relative">
{isStreaming && results.length === 0 ? (
<LoadingState
icon={Building2}
iconColor="text-primary"
bgColor="bg-primary/10"
title="Searching for companies"
filePath={query}
showProgress={true}
/>
) : results.length > 0 ? (
<ScrollArea className="h-full w-full">
<TooltipProvider>
<div className="p-4">
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200 mb-3 flex items-center justify-between">
<span>Found {total_results} companies</span>
</div>
<div className="space-y-3">
{results.map((result, idx) => {
const evaluations = parseEvaluations(result.evaluations);
const satisfiedCount = evaluations.filter(e => e.satisfied?.includes('Satisfied.yes')).length;
return (
<div
key={result.id || idx}
className="group relative border-b border-border last:border-b-0 pb-3 last:pb-0"
>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
{result.company_logo_url && result.company_logo_url !== '' ? (
<img
src={result.company_logo_url}
alt={result.company_name}
className="w-8 h-8 rounded-lg object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const fallback = target.nextSibling as HTMLElement;
if (fallback) fallback.style.display = 'flex';
}}
/>
) : null}
<div
className="w-8 h-8 rounded bg-muted flex items-center justify-center"
style={{ display: result.company_logo_url ? 'none' : 'flex' }}
>
<Building2 className="w-4 h-4 text-muted-foreground" />
</div>
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="font-medium text-sm text-foreground truncate">
{result.company_name || 'Unknown Company'}
</h3>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
{result.company_industry && (
<span className="truncate">{result.company_industry}</span>
)}
{result.company_industry && result.company_location && (
<span></span>
)}
{result.company_location && (
<span className="truncate">{result.company_location}</span>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{evaluations.length > 0 && (
<Tooltip>
<TooltipTrigger>
<Badge
variant="outline"
className={cn(
"text-xs h-5 px-1.5",
satisfiedCount > 0
? "border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-300"
: "border-muted-foreground/20"
)}
>
{satisfiedCount}/{evaluations.length}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<div className="space-y-1">
<p className="font-medium text-xs">Criteria match:</p>
{evaluations.map((evaluation, evalIdx) => (
<div key={evalIdx} className="text-xs flex items-center gap-2">
<div className={cn(
"w-1.5 h-1.5 rounded-full flex-shrink-0",
evaluation.satisfied?.includes('Satisfied.yes')
? "bg-emerald-400"
: "bg-muted-foreground"
)} />
<span className="truncate">{evaluation.criterion}</span>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-60 hover:opacity-100 transition-opacity"
asChild
>
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
title="View company details"
>
<ExternalLink className="h-3 w-3" />
</a>
</Button>
</div>
</div>
{result.description && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{truncateString(result.description, 120)}
</p>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</TooltipProvider>
</ScrollArea>
) : (
<div className="flex flex-col items-center justify-center h-full py-12 px-6 bg-zinc-50/50 dark:bg-zinc-900/50">
<div className="w-20 h-20 rounded-full flex items-center justify-center mb-6 bg-muted">
<Building2 className="h-10 w-10 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold mb-2 text-zinc-900 dark:text-zinc-100">
No Companies Found
</h3>
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-4 w-full max-w-md text-center mb-4">
<code className="text-sm font-mono text-zinc-700 dark:text-zinc-300 break-all">
{query || 'Unknown query'}
</code>
</div>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
Try refining your search criteria for better results
</p>
</div>
)}
</CardContent>
<div className="px-4 py-2 h-10 bg-zinc-50/90 dark:bg-zinc-900/90 backdrop-blur-sm border-t border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-4">
<div className="h-full flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
{!isStreaming && results.length > 0 && (
<Badge variant="outline" className="h-6 py-0.5 text-xs">
<Building2 className="h-3 w-3 mr-1" />
{results.length} results
</Badge>
)}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{actualToolTimestamp && !isStreaming
? formatTimestamp(actualToolTimestamp)
: actualAssistantTimestamp
? formatTimestamp(actualAssistantTimestamp)
: ''}
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,236 @@
import { extractToolData } from '../utils';
export interface CompanySearchResult {
rank: number;
id: string;
webset_id: string;
source: string;
source_id: string;
url: string;
type: string;
description: string;
company_name: string;
company_location: string;
company_industry: string;
company_logo_url: string;
evaluations: string;
enrichment_data: string;
created_at: string;
updated_at: string;
}
export interface CompanySearchData {
query: string | null;
total_results: number;
cost_deducted: string;
enrichment_type: string;
results: CompanySearchResult[];
success?: boolean;
timestamp?: string;
}
const parseContent = (content: any): any => {
if (typeof content === 'string') {
try {
return JSON.parse(content);
} catch (e) {
return content;
}
}
return content;
};
const extractFromNewFormat = (content: any): CompanySearchData => {
const parsedContent = parseContent(content);
if (!parsedContent || typeof parsedContent !== 'object') {
return {
query: null,
total_results: 0,
cost_deducted: '$0.54',
enrichment_type: '',
results: [],
success: undefined,
timestamp: undefined
};
}
if ('tool_execution' in parsedContent && typeof parsedContent.tool_execution === 'object') {
const toolExecution = parsedContent.tool_execution;
const args = toolExecution.arguments || {};
let parsedOutput = toolExecution.result?.output;
if (typeof parsedOutput === 'string') {
try {
parsedOutput = JSON.parse(parsedOutput);
} catch (e) {
}
}
parsedOutput = parsedOutput || {};
const extractedData = {
query: args.query || parsedOutput?.query || null,
total_results: parsedOutput?.total_results || 0,
cost_deducted: parsedOutput?.cost_deducted || '$0.54',
enrichment_type: args.enrichment_description || parsedOutput?.enrichment_type || '',
results: parsedOutput?.results?.map((result: any) => ({
rank: result.rank || 0,
id: result.id || '',
webset_id: result.webset_id || '',
source: result.source || '',
source_id: result.source_id || '',
url: result.url || '',
type: result.type || 'company',
description: result.description || '',
company_name: result.company_name || '',
company_location: result.company_location || '',
company_industry: result.company_industry || '',
company_logo_url: result.company_logo_url || '',
evaluations: result.evaluations || '',
enrichment_data: result.enrichment_data || '',
created_at: result.created_at || '',
updated_at: result.updated_at || ''
})) || [],
success: toolExecution.result?.success,
timestamp: toolExecution.execution_details?.timestamp
};
return extractedData;
}
if ('query' in parsedContent && 'results' in parsedContent) {
return {
query: parsedContent.query || null,
total_results: parsedContent.total_results || 0,
cost_deducted: parsedContent.cost_deducted || '$0.54',
enrichment_type: parsedContent.enrichment_type || '',
results: parsedContent.results?.map((result: any) => ({
rank: result.rank || 0,
id: result.id || '',
webset_id: result.webset_id || '',
source: result.source || '',
source_id: result.source_id || '',
url: result.url || '',
type: result.type || 'company',
description: result.description || '',
company_name: result.company_name || '',
company_location: result.company_location || '',
company_industry: result.company_industry || '',
company_logo_url: result.company_logo_url || '',
evaluations: result.evaluations || '',
enrichment_data: result.enrichment_data || '',
created_at: result.created_at || '',
updated_at: result.updated_at || ''
})) || [],
success: true,
timestamp: undefined
};
}
if ('role' in parsedContent && 'content' in parsedContent) {
return extractFromNewFormat(parsedContent.content);
}
return {
query: null,
total_results: 0,
cost_deducted: '$0.54',
enrichment_type: '',
results: [],
success: undefined,
timestamp: undefined
};
};
const extractFromLegacyFormat = (content: any): Omit<CompanySearchData, 'success' | 'timestamp'> => {
const toolData = extractToolData(content);
if (toolData.toolResult) {
const args = toolData.arguments || {};
return {
query: toolData.query || args.query || null,
total_results: 0,
cost_deducted: '$0.54',
enrichment_type: args.enrichment_description || '',
results: []
};
}
return {
query: null,
total_results: 0,
cost_deducted: '$0.54',
enrichment_type: '',
results: []
};
};
export function extractCompanySearchData(
assistantContent: any,
toolContent: any,
isSuccess: boolean,
toolTimestamp?: string,
assistantTimestamp?: string
): {
query: string | null;
total_results: number;
cost_deducted: string;
enrichment_type: string;
results: CompanySearchResult[];
actualIsSuccess: boolean;
actualToolTimestamp?: string;
actualAssistantTimestamp?: string;
} {
let data: CompanySearchData = {
query: null,
total_results: 0,
cost_deducted: '$0.54',
enrichment_type: '',
results: []
};
let actualIsSuccess = isSuccess;
let actualToolTimestamp = toolTimestamp;
let actualAssistantTimestamp = assistantTimestamp;
const assistantNewFormat = extractFromNewFormat(assistantContent);
const toolNewFormat = extractFromNewFormat(toolContent);
if (assistantNewFormat.query || assistantNewFormat.results.length > 0) {
data = assistantNewFormat;
if (assistantNewFormat.success !== undefined) {
actualIsSuccess = assistantNewFormat.success;
}
if (assistantNewFormat.timestamp) {
actualAssistantTimestamp = assistantNewFormat.timestamp;
}
} else if (toolNewFormat.query || toolNewFormat.results.length > 0) {
data = toolNewFormat;
if (toolNewFormat.success !== undefined) {
actualIsSuccess = toolNewFormat.success;
}
if (toolNewFormat.timestamp) {
actualToolTimestamp = toolNewFormat.timestamp;
}
} else {
const assistantLegacy = extractFromLegacyFormat(assistantContent);
const toolLegacy = extractFromLegacyFormat(toolContent);
data = {
...assistantLegacy,
...toolLegacy,
query: assistantLegacy.query || toolLegacy.query,
success: undefined,
timestamp: undefined
};
}
return {
query: data.query,
total_results: data.total_results,
cost_deducted: data.cost_deducted,
enrichment_type: data.enrichment_type,
results: data.results,
actualIsSuccess,
actualToolTimestamp,
actualAssistantTimestamp
};
}

View File

@ -0,0 +1,319 @@
import React from 'react';
import {
Users,
CheckCircle,
AlertTriangle,
ExternalLink,
MapPin,
Briefcase,
User,
Globe,
Award,
Mail,
Info
} from 'lucide-react';
import { ToolViewProps } from '../types';
import { formatTimestamp, getToolTitle } from '../utils';
import { truncateString } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { LoadingState } from '../shared/LoadingState';
import { extractPeopleSearchData } from './_utils';
import { cn } from '@/lib/utils';
export function PeopleSearchToolView({
name = 'people-search',
assistantContent,
toolContent,
assistantTimestamp,
toolTimestamp,
isSuccess = true,
isStreaming = false,
}: ToolViewProps) {
const {
query,
total_results,
cost_deducted,
results,
actualIsSuccess,
actualToolTimestamp,
actualAssistantTimestamp
} = extractPeopleSearchData(
assistantContent,
toolContent,
isSuccess,
toolTimestamp,
assistantTimestamp
);
const toolTitle = getToolTitle(name);
const parseEvaluations = (evaluationsText: string) => {
if (!evaluationsText) return [];
return evaluationsText.split(' | ').map(item => {
const [criterion, satisfied] = item.split(': ');
return { criterion, satisfied };
});
};
const parseEnrichmentData = (enrichmentData: string) => {
if (!enrichmentData) return {};
const parts = enrichmentData.split(' | ');
return {
linkedinUrl: parts[0] || '',
email: parts[1] !== 'null' ? parts[1] : '',
position: parts[2] || '',
company: parts[3] || ''
};
};
return (
<Card className="gap-0 flex border shadow-none border-t border-b-0 border-x-0 p-0 rounded-none flex-col h-full overflow-hidden bg-card">
<CardHeader className="h-14 bg-zinc-50/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b p-2 px-4 space-y-2">
<div className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<div className="relative p-2 rounded-xl bg-purple-500/20 border border-purple-500/20">
<Users className="w-5 h-5 text-purple-500 dark:text-purple-400" />
</div>
<div>
<CardTitle className="text-base font-medium text-zinc-900 dark:text-zinc-100">
{toolTitle}
</CardTitle>
</div>
</div>
{!isStreaming && (
<div className="flex items-center gap-2">
{cost_deducted && (
<Badge variant="outline" className="text-xs font-normal text-orange-600 dark:text-orange-400">
{cost_deducted}
</Badge>
)}
<Badge
variant="secondary"
className={
actualIsSuccess
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
: "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300"
}
>
{actualIsSuccess ? (
<CheckCircle className="h-3.5 w-3.5" />
) : (
<AlertTriangle className="h-3.5 w-3.5" />
)}
{actualIsSuccess ? 'Search completed' : 'Search failed'}
</Badge>
</div>
)}
</div>
</CardHeader>
<CardContent className="p-0 h-full flex-1 overflow-hidden relative">
{isStreaming && results.length === 0 ? (
<LoadingState
icon={Users}
iconColor="text-primary"
bgColor="bg-primary/10"
title="Searching for people"
filePath={query}
showProgress={true}
/>
) : results.length > 0 ? (
<ScrollArea className="h-full w-full">
<TooltipProvider>
<div className="p-3">
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200 mb-3 flex items-center justify-between">
<span>Found {total_results} people</span>
</div>
<div className="grid gap-2">
{results.map((result, idx) => {
const evaluations = parseEvaluations(result.evaluations);
const enrichment = parseEnrichmentData(result.enrichment_data);
const satisfiedCount = evaluations.filter(e => e.satisfied?.includes('Satisfied.yes')).length;
return (
<div
key={result.id || idx}
className="group bg-card border rounded-lg hover:border-primary/30"
>
<div className="p-3">
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
{result.person_picture_url && result.person_picture_url !== '' ? (
<img
src={result.person_picture_url}
alt={result.person_name}
className="w-10 h-10 rounded-full object-cover border border-zinc-200 dark:border-zinc-700"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='1.5'%3E%3Cpath d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'%3E%3C/path%3E%3Ccircle cx='12' cy='7' r='4'%3E%3C/circle%3E%3C/svg%3E";
target.classList.add("bg-zinc-100", "dark:bg-zinc-800", "p-2");
}}
/>
) : (
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center border">
<User className="w-5 h-5 text-muted-foreground" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 truncate">
{result.person_name || 'Unknown Person'}
</h3>
</div>
<div className="flex items-center gap-1">
{enrichment.email && (
<Tooltip>
<TooltipTrigger>
<Badge variant="secondary" className="text-xs px-1.5 py-0 h-5">
<Mail className="w-3 h-3" />
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>{enrichment.email}</p>
</TooltipContent>
</Tooltip>
)}
{evaluations.length > 0 && (
<Tooltip>
<TooltipTrigger>
<Badge
variant={satisfiedCount > 0 ? "default" : "secondary"}
className={cn(
"text-xs px-1.5 py-0 h-5",
satisfiedCount > 0
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
)}
>
<Award className="w-3 h-3 mr-1" />
{satisfiedCount}/{evaluations.length}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<div className="space-y-1">
<p className="font-medium">Evaluation Criteria:</p>
{evaluations.map((evaluation, evalIdx) => (
<div key={evalIdx} className="text-xs flex items-center gap-2">
<div className={cn(
"w-2 h-2 rounded-full flex-shrink-0",
evaluation.satisfied?.includes('Satisfied.yes')
? "bg-emerald-400"
: "bg-gray-400"
)} />
<span>{evaluation.criterion}</span>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
)}
{result.description && (
<Tooltip>
<TooltipTrigger>
<Info className="w-4 h-4 text-muted-foreground hover:text-primary transition-colors" />
</TooltipTrigger>
<TooltipContent className="max-w-sm">
<p className="text-xs whitespace-pre-wrap">{truncateString(result.description, 300)}</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
<div className="flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400">
<div className="flex items-center gap-3 min-w-0 flex-1">
{result.person_position && (
<div className="flex items-center gap-1 truncate">
<Briefcase className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{truncateString(result.person_position, 30)}</span>
</div>
)}
{result.person_location && (
<div className="flex items-center gap-1 truncate">
<MapPin className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{truncateString(result.person_location, 25)}</span>
</div>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
asChild
>
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
>
<Globe className="h-3 w-3" />
View
<ExternalLink className="h-3 w-3 ml-1" />
</a>
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</TooltipProvider>
</ScrollArea>
) : (
<div className="flex flex-col items-center justify-center h-full py-12 px-6 bg-zinc-50/50 dark:bg-zinc-900/50">
<div className="w-20 h-20 rounded-full flex items-center justify-center mb-6 bg-muted">
<Users className="h-10 w-10 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold mb-2 text-zinc-900 dark:text-zinc-100">
No People Found
</h3>
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-4 w-full max-w-md text-center mb-4 shadow-sm">
<code className="text-sm font-mono text-zinc-700 dark:text-zinc-300 break-all">
{query || 'Unknown query'}
</code>
</div>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
Try refining your search criteria for better results
</p>
</div>
)}
</CardContent>
<div className="px-4 py-2 h-10 bg-zinc-50/90 dark:bg-zinc-900/90 backdrop-blur-sm border-t border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-4">
<div className="h-full flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
{!isStreaming && results.length > 0 && (
<Badge variant="outline" className="h-6 py-0.5 text-xs">
<Users className="h-3 w-3 mr-1" />
{results.length} results
</Badge>
)}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{actualToolTimestamp && !isStreaming
? formatTimestamp(actualToolTimestamp)
: actualAssistantTimestamp
? formatTimestamp(actualAssistantTimestamp)
: ''}
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,236 @@
import { extractToolData } from '../utils';
export interface PeopleSearchResult {
rank: number;
id: string;
webset_id: string;
source: string;
source_id: string;
url: string;
type: string;
description: string;
person_name: string;
person_location: string;
person_position: string;
person_picture_url: string;
evaluations: string;
enrichment_data: string;
created_at: string;
updated_at: string;
}
export interface PeopleSearchData {
query: string | null;
total_results: number;
cost_deducted: string;
enrichment_type: string;
results: PeopleSearchResult[];
success?: boolean;
timestamp?: string;
}
const parseContent = (content: any): any => {
if (typeof content === 'string') {
try {
return JSON.parse(content);
} catch (e) {
return content;
}
}
return content;
};
const extractFromNewFormat = (content: any): PeopleSearchData => {
const parsedContent = parseContent(content);
if (!parsedContent || typeof parsedContent !== 'object') {
return {
query: null,
total_results: 0,
cost_deducted: '$0.54',
enrichment_type: '',
results: [],
success: undefined,
timestamp: undefined
};
}
if ('tool_execution' in parsedContent && typeof parsedContent.tool_execution === 'object') {
const toolExecution = parsedContent.tool_execution;
const args = toolExecution.arguments || {};
let parsedOutput = toolExecution.result?.output;
if (typeof parsedOutput === 'string') {
try {
parsedOutput = JSON.parse(parsedOutput);
} catch (e) {
}
}
parsedOutput = parsedOutput || {};
const extractedData = {
query: args.query || parsedOutput?.query || null,
total_results: parsedOutput?.total_results || 0,
cost_deducted: parsedOutput?.cost_deducted || '$0.54',
enrichment_type: args.enrichment_description || parsedOutput?.enrichment_type || '',
results: parsedOutput?.results?.map((result: any) => ({
rank: result.rank || 0,
id: result.id || '',
webset_id: result.webset_id || '',
source: result.source || '',
source_id: result.source_id || '',
url: result.url || '',
type: result.type || 'person',
description: result.description || '',
person_name: result.person_name || '',
person_location: result.person_location || '',
person_position: result.person_position || '',
person_picture_url: result.person_picture_url || '',
evaluations: result.evaluations || '',
enrichment_data: result.enrichment_data || '',
created_at: result.created_at || '',
updated_at: result.updated_at || ''
})) || [],
success: toolExecution.result?.success,
timestamp: toolExecution.execution_details?.timestamp
};
return extractedData;
}
if ('query' in parsedContent && 'results' in parsedContent) {
return {
query: parsedContent.query || null,
total_results: parsedContent.total_results || 0,
cost_deducted: parsedContent.cost_deducted || '$0.54',
enrichment_type: parsedContent.enrichment_type || '',
results: parsedContent.results?.map((result: any) => ({
rank: result.rank || 0,
id: result.id || '',
webset_id: result.webset_id || '',
source: result.source || '',
source_id: result.source_id || '',
url: result.url || '',
type: result.type || 'person',
description: result.description || '',
person_name: result.person_name || '',
person_location: result.person_location || '',
person_position: result.person_position || '',
person_picture_url: result.person_picture_url || '',
evaluations: result.evaluations || '',
enrichment_data: result.enrichment_data || '',
created_at: result.created_at || '',
updated_at: result.updated_at || ''
})) || [],
success: true,
timestamp: undefined
};
}
if ('role' in parsedContent && 'content' in parsedContent) {
return extractFromNewFormat(parsedContent.content);
}
return {
query: null,
total_results: 0,
cost_deducted: '$0.54',
enrichment_type: '',
results: [],
success: undefined,
timestamp: undefined
};
};
const extractFromLegacyFormat = (content: any): Omit<PeopleSearchData, 'success' | 'timestamp'> => {
const toolData = extractToolData(content);
if (toolData.toolResult) {
const args = toolData.arguments || {};
return {
query: toolData.query || args.query || null,
total_results: 0,
cost_deducted: '$0.54',
enrichment_type: args.enrichment_description || '',
results: []
};
}
return {
query: null,
total_results: 0,
cost_deducted: '$0.54',
enrichment_type: '',
results: []
};
};
export function extractPeopleSearchData(
assistantContent: any,
toolContent: any,
isSuccess: boolean,
toolTimestamp?: string,
assistantTimestamp?: string
): {
query: string | null;
total_results: number;
cost_deducted: string;
enrichment_type: string;
results: PeopleSearchResult[];
actualIsSuccess: boolean;
actualToolTimestamp?: string;
actualAssistantTimestamp?: string;
} {
let data: PeopleSearchData = {
query: null,
total_results: 0,
cost_deducted: '$0.54',
enrichment_type: '',
results: []
};
let actualIsSuccess = isSuccess;
let actualToolTimestamp = toolTimestamp;
let actualAssistantTimestamp = assistantTimestamp;
const assistantNewFormat = extractFromNewFormat(assistantContent);
const toolNewFormat = extractFromNewFormat(toolContent);
if (assistantNewFormat.query || assistantNewFormat.results.length > 0) {
data = assistantNewFormat;
if (assistantNewFormat.success !== undefined) {
actualIsSuccess = assistantNewFormat.success;
}
if (assistantNewFormat.timestamp) {
actualAssistantTimestamp = assistantNewFormat.timestamp;
}
} else if (toolNewFormat.query || toolNewFormat.results.length > 0) {
data = toolNewFormat;
if (toolNewFormat.success !== undefined) {
actualIsSuccess = toolNewFormat.success;
}
if (toolNewFormat.timestamp) {
actualToolTimestamp = toolNewFormat.timestamp;
}
} else {
const assistantLegacy = extractFromLegacyFormat(assistantContent);
const toolLegacy = extractFromLegacyFormat(toolContent);
data = {
...assistantLegacy,
...toolLegacy,
query: assistantLegacy.query || toolLegacy.query,
success: undefined,
timestamp: undefined
};
}
return {
query: data.query,
total_results: data.total_results,
cost_deducted: data.cost_deducted,
enrichment_type: data.enrichment_type,
results: data.results,
actualIsSuccess,
actualToolTimestamp,
actualAssistantTimestamp
};
}

View File

@ -11,6 +11,8 @@ import { StrReplaceToolView } from '../str-replace/StrReplaceToolView';
import { WebCrawlToolView } from '../WebCrawlToolView';
import { WebScrapeToolView } from '../web-scrape-tool/WebScrapeToolView';
import { WebSearchToolView } from '../web-search-tool/WebSearchToolView';
import { PeopleSearchToolView } from '../people-search-tool/PeopleSearchToolView';
import { CompanySearchToolView } from '../company-search-tool/CompanySearchToolView';
import { SeeImageToolView } from '../see-image-tool/SeeImageToolView';
import { TerminateCommandToolView } from '../command-tool/TerminateCommandToolView';
import { AskToolView } from '../ask-tool/AskToolView';
@ -80,6 +82,8 @@ const defaultRegistry: ToolViewRegistryType = {
'str-replace': StrReplaceToolView,
'web-search': WebSearchToolView,
'people-search': PeopleSearchToolView,
'company-search': CompanySearchToolView,
'crawl-webpage': WebCrawlToolView,
'scrape-webpage': WebScrapeToolView,
'image-search': WebSearchToolView,

View File

@ -138,6 +138,7 @@ def load_existing_env_vars():
"TAVILY_API_KEY": backend_env.get("TAVILY_API_KEY", ""),
"FIRECRAWL_API_KEY": backend_env.get("FIRECRAWL_API_KEY", ""),
"FIRECRAWL_URL": backend_env.get("FIRECRAWL_URL", ""),
"EXA_API_KEY": backend_env.get("EXA_API_KEY", ""),
},
"rapidapi": {
"RAPID_API_KEY": backend_env.get("RAPID_API_KEY", ""),
@ -848,9 +849,10 @@ class SetupWizard:
)
else:
print_info(
"Suna uses Tavily for search and Firecrawl for web scraping.")
"Suna uses Tavily for search, Firecrawl for web scraping, and Exa for people search.")
print_info(
"Get a Tavily key at https://tavily.com and a Firecrawl key at https://firecrawl.dev"
"Get a Tavily key at https://tavily.com, a Firecrawl key at https://firecrawl.dev, "
"and an Exa key at https://exa.ai"
)
input("Press Enter to continue once you have your keys...")
@ -866,6 +868,20 @@ class SetupWizard:
"Invalid API key.",
default_value=self.env_vars["search"]["FIRECRAWL_API_KEY"],
)
# Exa API key (optional for people search)
print_info(
"\nExa API enables advanced people search with LinkedIn/email enrichment using Websets."
)
print_info(
"This is optional but required for the People Search tool. Leave blank to skip."
)
self.env_vars["search"]["EXA_API_KEY"] = self._get_input(
"Enter your Exa API key (optional): ",
lambda x: x == "" or validate_api_key(x),
"Invalid API key.",
default_value=self.env_vars["search"]["EXA_API_KEY"],
)
# Handle Firecrawl URL configuration
current_url = self.env_vars["search"]["FIRECRAWL_URL"]