Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions astrbot/core/astr_main_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@
BaiduWebSearchTool,
BochaWebSearchTool,
BraveWebSearchTool,
ExaGetContentsTool,
ExaWebSearchTool,
FirecrawlExtractWebPageTool,
FirecrawlWebSearchTool,
TavilyExtractWebPageTool,
Expand Down Expand Up @@ -130,6 +132,7 @@
"web_search_tavily",
"web_search_bocha",
"web_search_brave",
"web_search_exa",
}
)
WEB_SEARCH_CITATION_PROMPT = (
Expand Down Expand Up @@ -1207,6 +1210,9 @@ async def _apply_web_search_tools(
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FirecrawlExtractWebPageTool))
elif provider == "baidu_ai_search":
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BaiduWebSearchTool))
elif provider == "exa":
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExaWebSearchTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExaGetContentsTool))


def _apply_web_search_citation_prompt(
Expand Down
12 changes: 12 additions & 0 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
"websearch_brave_key": [],
"websearch_baidu_app_builder_key": "",
"websearch_firecrawl_key": [],
"websearch_exa_key": [],
"web_search_link": False,
"display_reasoning_text": False,
"identifier": False,
Expand Down Expand Up @@ -3295,6 +3296,7 @@
"bocha",
"brave",
"firecrawl",
"exa",
],
"condition": {
"provider_settings.web_search": True,
Expand Down Expand Up @@ -3349,6 +3351,16 @@
"provider_settings.web_search": True,
},
},
"provider_settings.websearch_exa_key": {
"description": "Exa API Key",
"type": "list",
"items": {"type": "string"},
"hint": "可添加多个 Key 进行轮询。Get a key at https://dashboard.exa.ai",
"condition": {
"provider_settings.websearch_provider": "exa",
"provider_settings.web_search": True,
},
},
"provider_settings.web_search_link": {
"description": "显示来源引用",
"type": "bool",
Expand Down
229 changes: 229 additions & 0 deletions astrbot/core/tools/web_search_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"web_search_brave",
"web_search_firecrawl",
"firecrawl_extract_web_page",
"web_search_exa",
"exa_get_contents",
]
_TAVILY_WEB_SEARCH_TOOL_CONFIG = {
"provider_settings.web_search": True,
Expand All @@ -42,6 +44,10 @@
"provider_settings.web_search": True,
"provider_settings.websearch_provider": "baidu_ai_search",
}
_EXA_WEB_SEARCH_TOOL_CONFIG = {
"provider_settings.web_search": True,
"provider_settings.websearch_provider": "exa",
}


@std_dataclass
Expand Down Expand Up @@ -76,6 +82,7 @@ async def get(self, provider_settings: dict) -> str:
_BOCHA_KEY_ROTATOR = _KeyRotator("websearch_bocha_key", "BoCha")
_BRAVE_KEY_ROTATOR = _KeyRotator("websearch_brave_key", "Brave")
_FIRECRAWL_KEY_ROTATOR = _KeyRotator("websearch_firecrawl_key", "Firecrawl")
_EXA_KEY_ROTATOR = _KeyRotator("websearch_exa_key", "Exa")


def normalize_legacy_web_search_config(cfg) -> None:
Expand All @@ -99,6 +106,7 @@ def normalize_legacy_web_search_config(cfg) -> None:
"websearch_bocha_key",
"websearch_brave_key",
"websearch_firecrawl_key",
"websearch_exa_key",
):
value = provider_settings.get(setting_name)
if isinstance(value, str):
Expand Down Expand Up @@ -803,10 +811,231 @@ async def call(self, context, **kwargs) -> ToolExecResult:
return _search_result_payload(results)


async def _exa_search(
provider_settings: dict,
payload: dict,
) -> list[SearchResult]:
"""Call the Exa /search endpoint and return normalized results."""
exa_key = await _EXA_KEY_ROTATOR.get(provider_settings)
headers = {
"x-api-key": exa_key,
"Content-Type": "application/json",
}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
"https://api.exa.ai/search",
json=payload,
headers=headers,
) as response:
if response.status != 200:
reason = await response.text()
raise Exception(
f"Exa web search failed: {reason}, status: {response.status}",
)
data = await response.json()
return [
SearchResult(
title=item.get("title", ""),
url=item.get("url", ""),
snippet=(
item.get("text")
or (item.get("highlights") or [""])[0]
or item.get("summary", "")
),
Comment on lines +840 to +844

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the Exa API returns highlights as an empty list ([]), item.get("highlights", [""]) will return [] because the key exists. Accessing the first element with [0] will then raise an IndexError: list index out of range and crash the search. Using (item.get("highlights") or [""])[0] safely handles empty lists, None, or missing keys.

Suggested change
snippet=(
item.get("text")
or item.get("highlights", [""])[0]
or item.get("summary", "")
),
snippet=(
item.get("text")
or (item.get("highlights") or [""])[0]
or item.get("summary", "")
),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in d68f83d. Now uses (item.get("highlights") or [""])[0] which safely handles empty lists, None, and missing keys.

)
for item in data.get("results", [])
if item.get("url")
]


async def _exa_get_contents(
provider_settings: dict,
payload: dict,
) -> list[dict]:
"""Call the Exa /contents endpoint and return raw result dicts."""
exa_key = await _EXA_KEY_ROTATOR.get(provider_settings)
headers = {
"x-api-key": exa_key,
"Content-Type": "application/json",
}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
"https://api.exa.ai/contents",
json=payload,
headers=headers,
) as response:
if response.status != 200:
reason = await response.text()
raise Exception(
f"Exa get contents failed: {reason}, status: {response.status}",
)
data = await response.json()
return data.get("results", [])


@builtin_tool(config=_EXA_WEB_SEARCH_TOOL_CONFIG)
@pydantic_dataclass
class ExaWebSearchTool(FunctionTool[AstrAgentContext]):
"""Web search tool powered by the Exa Search API."""

name: str = "web_search_exa"
description: str = (
"A web search tool powered by Exa, an AI-native search engine. "
"Supports keyword and semantic search with domain, date, and category filters."
)
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Required. Search query."},
"num_results": {
"type": "integer",
"description": "Optional. Number of results to return. Default is 10.",
},
"type": {
"type": "string",
"description": (
'Optional. Search type. One of "auto", "keyword", "neural". '
'Default is "auto".'
),
},
"category": {
"type": "string",
"description": (
"Optional. Category filter. One of "
'"company", "research paper", "news", "github", '
'"tweet", "personal site", "pdf", "linkedin profile".'
),
},
"include_domains": {
"type": "string",
"description": "Optional. Comma-separated domains to restrict results to.",
},
"exclude_domains": {
"type": "string",
"description": "Optional. Comma-separated domains to exclude from results.",
},
"start_published_date": {
"type": "string",
"description": "Optional. Start date filter in ISO 8601 format (e.g. 2024-01-01T00:00:00.000Z).",
},
"end_published_date": {
"type": "string",
"description": "Optional. End date filter in ISO 8601 format.",
},
},
"required": ["query"],
}
)

async def call(self, context, **kwargs) -> ToolExecResult:
_, provider_settings, _ = _get_runtime(context)
if not provider_settings.get("websearch_exa_key", []):
return "Error: Exa API key is not configured in AstrBot."

try:
num_results = int(kwargs.get("num_results", 10))
except (TypeError, ValueError):
num_results = 10
if num_results < 1:
num_results = 1
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

search_type = kwargs.get("type", "auto")
if search_type not in ("auto", "keyword", "neural"):
search_type = "auto"

payload: dict = {
"query": kwargs["query"],
"numResults": num_results,
"type": search_type,
"contents": {"text": {"maxCharacters": 500}},
}

category = kwargs.get("category", "")
if category:
payload["category"] = category

include_domains = str(kwargs.get("include_domains", "")).strip()
if include_domains:
payload["includeDomains"] = [
d.strip() for d in include_domains.split(",") if d.strip()
]

exclude_domains = str(kwargs.get("exclude_domains", "")).strip()
if exclude_domains:
payload["excludeDomains"] = [
d.strip() for d in exclude_domains.split(",") if d.strip()
]

if kwargs.get("start_published_date"):
payload["startPublishedDate"] = kwargs["start_published_date"]
if kwargs.get("end_published_date"):
payload["endPublishedDate"] = kwargs["end_published_date"]

results = await _exa_search(provider_settings, payload)
if not results:
return "Error: Exa web search does not return any results."
return _search_result_payload(results)


@builtin_tool(config=_EXA_WEB_SEARCH_TOOL_CONFIG)
@pydantic_dataclass
class ExaGetContentsTool(FunctionTool[AstrAgentContext]):
"""Extract full page content from URLs using the Exa Contents API."""

name: str = "exa_get_contents"
description: str = "Extract the content of a web page using Exa."
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Required. A URL to extract content from.",
},
"max_characters": {
"type": "integer",
"description": "Optional. Maximum number of characters to return. Default is 3000.",
},
},
"required": ["url"],
}
)

async def call(self, context, **kwargs) -> ToolExecResult:
_, provider_settings, _ = _get_runtime(context)
if not provider_settings.get("websearch_exa_key", []):
return "Error: Exa API key is not configured in AstrBot."

url = str(kwargs.get("url", "")).strip()
if not url:
return "Error: url must be a non-empty string."

try:
max_characters = int(kwargs.get("max_characters", 3000))
except (TypeError, ValueError):
max_characters = 3000
results = await _exa_get_contents(
provider_settings,
{
"ids": [url],
"text": {"maxCharacters": max_characters},
},
)
ret_ls = []
for result in results:
ret_ls.append(f"URL: {result.get('url', 'No URL')}")
ret_ls.append(f"Content: {result.get('text', 'No content')}")
ret = "\n".join(ret_ls)
return ret or "Error: Exa get contents does not return any results."


__all__ = [
"BaiduWebSearchTool",
"BochaWebSearchTool",
"BraveWebSearchTool",
"ExaGetContentsTool",
"ExaWebSearchTool",
"TavilyExtractWebPageTool",
"TavilyWebSearchTool",
"WEB_SEARCH_TOOL_NAMES",
Expand Down
20 changes: 12 additions & 8 deletions dashboard/src/i18n/locales/en-US/features/config-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@
},
"web_search_link": {
"description": "Display Source Citations"
},
"websearch_exa_key": {
"description": "Exa API Key",
"hint": "Multiple keys can be added for rotation. Get a key at https://dashboard.exa.ai"
}
}
},
Expand Down Expand Up @@ -1221,22 +1225,22 @@
"hint": "Only effective for qwen3-rerank models. Recommended to write in English."
},
"nvidia_rerank_api_base": {
"description": "API Base URL"
"description": "API Base URL"
},
"nvidia_rerank_api_key": {
"description": "API Key"
"description": "API Key"
},
"nvidia_rerank_model": {
"description": "Rerank Model Name",
"hint": "Please refer to the NVIDIA Docs for the model name."
"description": "Rerank Model Name",
"hint": "Please refer to the NVIDIA Docs for the model name."
},
"nvidia_rerank_model_endpoint": {
"description": "Custom Model Endpoint",
"hint": "Custom URL suffix endpoint, defaults to /reranking."
"description": "Custom Model Endpoint",
"hint": "Custom URL suffix endpoint, defaults to /reranking."
},
"nvidia_rerank_truncate": {
"description": "Text Truncation Strategy",
"hint": "Whether to truncate the input to fit the model's maximum context length when the input text is too long."
"description": "Text Truncation Strategy",
"hint": "Whether to truncate the input to fit the model's maximum context length when the input text is too long."
},
"launch_model_if_not_running": {
"description": "Auto-start model if not running",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@
},
"web_search_link": {
"description": "Показывать ссылки на источники"
},
"websearch_exa_key": {
"description": "API-ключ Exa",
"hint": "Можно добавить несколько ключей для ротации. Получить ключ: https://dashboard.exa.ai"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@
},
"web_search_link": {
"description": "显示来源引用"
},
"websearch_exa_key": {
"description": "Exa API Key",
"hint": "可添加多个 Key 进行轮询。获取 Key: https://dashboard.exa.ai"
}
}
},
Expand Down
Loading
Loading