Skip to content
Open
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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,25 @@ Verify at [http://localhost:7474](http://localhost:7474)

---

### Web search

Issue-analysis agents use [Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp)
when no Tavily key is configured. Search runs in Fast mode without an account or API key.
The anonymous service has usage limits; requests can return quota errors.

`PROMETHEUS_WEB_SEARCH_PROVIDER` defaults to `auto`: an existing
`PROMETHEUS_TAVILY_API_KEY` keeps Tavily selected. Set the provider to `parallel`
or `tavily` to choose explicitly, or `disabled` to remove web search from the agents.
An explicitly selected Tavily provider requires a Tavily key. Provider failures
are reported without switching providers. `PROMETHEUS_WEB_SEARCH_TIMEOUT` bounds
Parallel requests, including connection setup (30 seconds by default).

Parallel uses server-managed result counts and source selection. Custom result
limits and domain filters remain available through Tavily; requesting them with
Parallel raises an error. The agent-facing tool accepts a `query` with either provider.
Queries are sent to the selected external service; use `disabled` when external
web search is not permitted.

## 🤝 Contributing

We welcome contributions! Please see our **[Contributing Guidelines](CONTRIBUTING.md)** for details on how to get started.
Expand Down
8 changes: 6 additions & 2 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,12 @@ PROMETHEUS_ADVANCED_MODEL_TEMPERATURE=0.5

PROMETHEUS_BASE_MODEL_TEMPERATURE=0.5

# Tavily API settings (You can sign up for a free API key at https://www.tavily.com/)
PROMETHEUS_TAVILY_API_KEY=your_tavily_api_key
# Web search: auto uses a configured Tavily key, otherwise free Parallel Search MCP.
# Use parallel or tavily to select explicitly, or disabled to remove search tools.
PROMETHEUS_WEB_SEARCH_PROVIDER=auto
PROMETHEUS_WEB_SEARCH_TIMEOUT=30
# Optional Tavily API key (https://www.tavily.com/)
# PROMETHEUS_TAVILY_API_KEY=your_tavily_api_key

# Database settings
PROMETHEUS_DATABASE_URL=postgresql+asyncpg://postgres:password@postgres:5432/postgres
Expand Down
7 changes: 5 additions & 2 deletions prometheus/configuration/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import List, Literal, Optional

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


Expand Down Expand Up @@ -62,8 +63,10 @@ class Settings(BaseSettings):
# Default normal user repository number
DEFAULT_USER_REPOSITORY_LIMIT: int = 5

# tool for Websearch
TAVILY_API_KEY: str
# Web search: retain configured Tavily access; otherwise use keyless Parallel.
WEB_SEARCH_PROVIDER: Literal["auto", "parallel", "tavily", "disabled"] = "auto"
WEB_SEARCH_TIMEOUT: float = Field(default=30.0, gt=0, le=300)
TAVILY_API_KEY: Optional[str] = None

# Athena semantic memory service
ATHENA_BASE_URL: Optional[str] = None
Expand Down
12 changes: 5 additions & 7 deletions prometheus/lang_graph/nodes/issue_bug_analyzer_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,7 @@ class IssueBugAnalyzerNode:

Tools available:
- web_search: Searches the web for technical information to aid in bug analysis and resolution.
When using the web_search tool, it's recommended to include these parameters:
- include_domains: ['stackoverflow.com', 'github.com', 'developer.mozilla.org', 'learn.microsoft.com', 'fastapi.tiangolo.com'
'docs.python.org', 'pydantic.dev', 'pypi.org', 'readthedocs.org', 'docs.djangoproject.com','flask.palletsprojects.com']
- search_depth: "advanced"

Make sure to explicitly pass these parameters in your tool call.
Pass a specific technical query to web_search.

Important:
- Do NOT provide actual code snippets or diffs
Expand All @@ -72,16 +67,19 @@ def __init__(self, model: BaseChatModel):
self.model = model
self.system_prompt = SystemMessage(self.SYS_PROMPT)
self.tools = self._init_tools()
self.model_with_tools = model.bind_tools(self.tools)
self.model_with_tools = model.bind_tools(self.tools) if self.tools else model
self._logger = logging.getLogger(f"thread-{threading.get_ident()}.{__name__}")

def _init_tools(self):
"""Initializes tools for the node."""
tools = []
if self.web_search_tool.provider == "disabled":
return tools

web_search_fn = functools.partial(self.web_search_tool.web_search)
web_search_tool = StructuredTool.from_function(
func=web_search_fn,
coroutine=self.web_search_tool.aweb_search,
name=self.web_search_tool.web_search.__name__,
description=self.web_search_tool.web_search_spec.description,
args_schema=self.web_search_tool.web_search_spec.input_schema,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,20 @@ def __init__(self, model: BaseChatModel):
self.system_prompt = SystemMessage(self.SYS_PROMPT)
self.web_search_tool = WebSearchTool()
self.tools = self._init_tools()
self.model_with_tools = model.bind_tools(self.tools)
self.model_with_tools = model.bind_tools(self.tools) if self.tools else model

self._logger = logging.getLogger(f"thread-{threading.get_ident()}.{__name__}")

def _init_tools(self):
"""Initializes tools for the node."""
tools = []
if self.web_search_tool.provider == "disabled":
return tools

web_search_fn = functools.partial(self.web_search_tool.web_search)
web_search_tool = StructuredTool.from_function(
func=web_search_fn,
coroutine=self.web_search_tool.aweb_search,
name=self.web_search_tool.web_search.__name__,
description=self.web_search_tool.web_search_spec.description,
args_schema=self.web_search_tool.web_search_spec.input_schema,
Expand Down
12 changes: 5 additions & 7 deletions prometheus/lang_graph/nodes/issue_feature_analyzer_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,7 @@ class IssueFeatureAnalyzerNode:

Tools available:
- web_search: Searches the web for technical information to aid in feature design and implementation.
When using the web_search tool, it's recommended to include these parameters:
- include_domains: ['stackoverflow.com', 'github.com', 'developer.mozilla.org', 'learn.microsoft.com', 'fastapi.tiangolo.com'
'docs.python.org', 'pydantic.dev', 'pypi.org', 'readthedocs.org', 'docs.djangoproject.com','flask.palletsprojects.com']
- search_depth: "advanced"

Make sure to explicitly pass these parameters in your tool call.
Pass a specific technical query to web_search.

Important:
- Do NOT provide actual code snippets or diffs
Expand All @@ -74,16 +69,19 @@ def __init__(self, model: BaseChatModel):
self.model = model
self.system_prompt = SystemMessage(self.SYS_PROMPT)
self.tools = self._init_tools()
self.model_with_tools = model.bind_tools(self.tools)
self.model_with_tools = model.bind_tools(self.tools) if self.tools else model
self._logger = logging.getLogger(f"thread-{threading.get_ident()}.{__name__}")

def _init_tools(self):
"""Initializes tools for the node."""
tools = []
if self.web_search_tool.provider == "disabled":
return tools

web_search_fn = functools.partial(self.web_search_tool.web_search)
web_search_tool = StructuredTool.from_function(
func=web_search_fn,
coroutine=self.web_search_tool.aweb_search,
name=self.web_search_tool.web_search.__name__,
description=self.web_search_tool.web_search_spec.description,
args_schema=self.web_search_tool.web_search_spec.input_schema,
Expand Down
5 changes: 4 additions & 1 deletion prometheus/lang_graph/nodes/issue_question_analyzer_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,20 @@ def __init__(self, model: BaseChatModel):
self.system_prompt = SystemMessage(self.SYS_PROMPT)
self.web_search_tool = WebSearchTool()
self.tools = self._init_tools()
self.model_with_tools = model.bind_tools(self.tools)
self.model_with_tools = model.bind_tools(self.tools) if self.tools else model

self._logger = logging.getLogger(f"thread-{threading.get_ident()}.{__name__}")

def _init_tools(self):
"""Initializes tools for the node."""
tools = []
if self.web_search_tool.provider == "disabled":
return tools

web_search_fn = functools.partial(self.web_search_tool.web_search)
web_search_tool = StructuredTool.from_function(
func=web_search_fn,
coroutine=self.web_search_tool.aweb_search,
name=self.web_search_tool.web_search.__name__,
description=self.web_search_tool.web_search_spec.description,
args_schema=self.web_search_tool.web_search_spec.input_schema,
Expand Down
87 changes: 78 additions & 9 deletions prometheus/tools/web_search.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import asyncio
import logging
import threading
from dataclasses import dataclass
from typing import Annotated

from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from mcp.types import Implementation
from pydantic import BaseModel, Field
from tavily import InvalidAPIKeyError, TavilyClient, UsageLimitExceededError

Expand All @@ -17,7 +21,7 @@ class ToolSpec:


class WebSearchInput(BaseModel):
"""Base parameters for Tavily search."""
"""Parameters shared by the web search providers."""

query: Annotated[str, Field(description="Search query")]

Expand Down Expand Up @@ -79,8 +83,12 @@ def __init__(self):
self._logger = logging.getLogger(f"thread-{threading.get_ident()}.{__name__}")

tavily_api_key = settings.TAVILY_API_KEY
if tavily_api_key is None:
self._logger.warning("Tavily API key is not set")
provider = settings.WEB_SEARCH_PROVIDER
self.provider = (
("tavily" if tavily_api_key else "parallel") if provider == "auto" else provider
)
self.timeout = settings.WEB_SEARCH_TIMEOUT
if self.provider != "tavily" or not tavily_api_key:
tavily_client = None
else:
tavily_client = TavilyClient(api_key=tavily_api_key)
Expand All @@ -89,21 +97,30 @@ def __init__(self):
def web_search(
self,
query: str,
max_results: int = 5,
max_results: int | None = None,
include_domains=None,
exclude_domains: list[str] = None,
exclude_domains: list[str] | None = None,
) -> str:
"""Search the web for technical information to aid in bug analysis and resolution.

Args:
query: Search query string.
max_results: Maximum number of results to return (default: 5).
include_domains: List of domains to include in search.
exclude_domains: List of domains to exclude from search.
max_results: Tavily result limit (default: 5); Parallel uses server settings.
include_domains: Tavily domains to include; unsupported on Parallel.
exclude_domains: Tavily domains to exclude; unsupported on Parallel.

Returns:
Formatted search results as a string.
"""
if self.provider == "parallel":
return asyncio.run(
self.aweb_search(query, max_results, include_domains, exclude_domains)
)
if self.provider == "disabled":
raise WebSearchToolException("Web search is disabled")
if self.tavily_client is None:
raise WebSearchToolException("Tavily requires PROMETHEUS_TAVILY_API_KEY")

# Set default include domains if not provided
if include_domains is None:
include_domains = [
Expand All @@ -123,7 +140,7 @@ def web_search(
try:
response = self.tavily_client.search(
query=query,
max_results=max_results,
max_results=5 if max_results is None else max_results,
search_depth="advanced",
include_answer=True,
include_domains=include_domains or [], # Convert None to an empty list
Expand All @@ -140,3 +157,55 @@ def web_search(
format_response = format_results(response)
self._logger.info(f"web_search format_response: {format_response}")
return format_response

async def aweb_search(
self,
query: str,
max_results: int | None = None,
include_domains=None,
exclude_domains: list[str] | None = None,
) -> str:
"""Search asynchronously, allowing Parallel requests to be cancelled."""
if self.provider != "parallel":
return await asyncio.to_thread(
self.web_search, query, max_results, include_domains, exclude_domains
)
if max_results is not None or include_domains or exclude_domains:
raise WebSearchToolException(
"Parallel uses server-managed search settings. Select the tavily provider "
"to use domain filters or custom result limits."
)
try:
return await asyncio.wait_for(self._parallel_search(query), timeout=self.timeout)
except TimeoutError:
raise WebSearchToolException("Parallel search timed out") from None
except WebSearchToolException:
raise
except Exception: # noqa: BLE001 - sanitize SDK exceptions before model consumption.
# SDK transport errors can contain request details. Keep the public error concise.
raise WebSearchToolException("Parallel Search MCP request failed") from None

async def _parallel_search(self, query: str) -> str:
# Each call owns and closes its transport. Do not attach a process/client-wide
# conversation ID: analyzer instances are reused across independent issue runs.
headers = {"User-Agent": "Prometheus/1.3 (+https://github.com/EuniAI/Prometheus)"}
async with (
streamablehttp_client("https://search.parallel.ai/mcp", headers=headers) as (
read,
write,
_,
),
ClientSession(
read, write, client_info=Implementation(name="Prometheus", version="1.3")
) as session,
):
await session.initialize()
result = await session.call_tool(
"web_search", {"objective": query, "search_queries": [query]}
)
text = "\n".join(block.text for block in result.content if block.type == "text")
if result.isError:
raise WebSearchToolException(f"Parallel Search MCP returned an error: {text}")
if not text.strip():
raise WebSearchToolException("Parallel Search MCP returned no text results")
return text
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ dependencies = [
"sqlmodel==0.0.24",
"asyncpg",
"pyjwt==2.6.0",
"mcp>=1.4.1",
"mcp>=1.10.1,<2",
# MCP versions compatible with the pinned PyJWT require Pydantic <2.12.
"pydantic>=2.7,<2.12",
"tavily-python>=0.5.1",
"langchain-mcp-adapters>=0.1.9",
"httpx==0.28.1",
Expand Down
Loading
Loading