2 Commits

Author SHA1 Message Date
mi222eh 5f35f56edc Refactor code structure for improved readability and maintainability 2026-07-06 21:37:20 +02:00
mi222eh 6ca6a5687a Initial commit 2026-07-02 19:03:58 +02:00
5 changed files with 80 additions and 110 deletions
-55
View File
@@ -1,55 +0,0 @@
# Web Search Skill
## Purpose
Enable the agent to search the web using a self-hosted SearXNG instance for privacy-respecting searches.
## Configuration
- SearXNG Instance: `https://searxng.meeks.freeddns.org`
- API Endpoint: `/search`
- Supported Formats: `json`, `csv`, `rss`
## Usage
### Search the Web
When you need to look up information, find recent developments, or verify facts:
```
search_web(query="what is the latest version of Python", limit=10, language="en")
```
### Parameters
- `query` (required): The search query string
- `limit` (optional): Number of results to return (default: 10, max: 100)
- `language` (optional): Language code (e.g., "en", "sv", "de")
- `time_range` (optional): Time filter - "day", "month", or "year"
- `categories` (optional): Search category - "general", "images", "news", "videos", "science", "it", "music", "files", "map", "realtime"
### Example Queries
- `search_web(query="uv python package manager tutorial")`
- `search_web(query="searxng API documentation", time_range="month")`
- `search_web(query="gitea vs github", categories="general")`
## Implementation Details
The skill uses the SearXNG JSON API:
```
GET https://searxng.meeks.freeddns.org/search?q={query}&format=json&limit={limit}&language={language}
```
### Error Handling
- If the SearXNG instance is unreachable, log the error and try alternative search methods
- If the response format is invalid, parse what's available and report the issue
- If rate limited, wait and retry with exponential backoff
## When to Use
- Researching technical issues or solutions
- Looking up recent software updates or versions
- Finding documentation or tutorials
- Verifying facts or current information
- Investigating error messages or stack traces
- Finding similar projects or libraries
## When NOT to Use
- When you already have the information locally
- For simple factual questions that don't require current data
- When the user explicitly asks not to search the web
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 mi222eh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11
View File
@@ -13,6 +13,9 @@ class AgentSettings(BaseSettings):
gitea_repos_root: str = ""
agent_model_id: str = "qwen/qwen3.6-35b-a3b"
agent_max_retries: int = 2
searxng_url: str = ""
searxng_username: str = ""
searxng_password: str = ""
def get_settings() -> AgentSettings:
@@ -28,7 +31,15 @@ GITEA_TOKEN: str = _agent_settings.gitea_token
GITEA_REPOS_ROOT: str = _agent_settings.gitea_repos_root
AGENT_MODEL_ID: str = _agent_settings.agent_model_id
AGENT_MAX_RETRIES: int = _agent_settings.agent_max_retries
SEARXNG_URL: str = _agent_settings.searxng_url
SEARXNG_USERNAME: str = _agent_settings.searxng_username
SEARXNG_PASSWORD: str = _agent_settings.searxng_password
import os
os.environ["GITEA_SERVER_URL"] = GITEA_URL
os.environ["GITEA_SERVER_TOKEN"] = GITEA_TOKEN
os.environ["SEARXNG_URL"] = SEARXNG_URL
os.environ["SEARXNG_USERNAME"] = SEARXNG_USERNAME
os.environ["SEARXNG_PASSWORD"] = SEARXNG_PASSWORD
+21 -21
View File
@@ -20,6 +20,13 @@ import time
from typing import Any
import httpx
from duckduckgo_search import DDGS # type: ignore[import-untyped]
from duckduckgo_search.exceptions import ( # type: ignore[import-untyped]
DuckDuckGoSearchException,
RatelimitException,
)
from gitea.config import SEARXNG_URL, SEARXNG_USERNAME, SEARXNG_PASSWORD
logger: logging.Logger = logging.getLogger("research-tools")
@@ -29,10 +36,12 @@ _USER_AGENT: str = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
# SearXNG instance — override with SEARXNG_URL env var
_SEARXNG_URL: str = os.getenv(
"SEARXNG_URL", "https://searxng.meeks.freeddns.org"
)
# SearXNG instance
_SEARXNG_URL: str = SEARXNG_URL
_SEARXNG_USERNAME: str = SEARXNG_USERNAME
_SEARXNG_PASSWORD: str = SEARXNG_PASSWORD
class ResearchTools:
@@ -178,6 +187,9 @@ class ResearchTools:
Returns formatted results string on success, or None if the instance
is unreachable so the caller can fall through to the next backend.
"""
if not _SEARXNG_URL:
logger.info("SearXNG URL is not configured; skipping SearXNG search.")
return None
params: dict[str, Any] = {
"q": query,
"format": "json",
@@ -187,9 +199,13 @@ class ResearchTools:
if time_range:
params["time_range"] = time_range
auth = None
if _SEARXNG_USERNAME and _SEARXNG_PASSWORD:
auth = (_SEARXNG_USERNAME, _SEARXNG_PASSWORD)
try:
with httpx.Client(
timeout=_DEFAULT_TIMEOUT, follow_redirects=True
timeout=_DEFAULT_TIMEOUT, follow_redirects=True, auth=auth
) as client:
response = client.get(
f"{_SEARXNG_URL}/search",
@@ -307,22 +323,6 @@ class ResearchTools:
logger.info("SearXNG unavailable; falling back to DuckDuckGo")
# --- Tier 2: DDGS library (handles sessions, cookies, rate limits) ---
try:
from duckduckgo_search import DDGS # type: ignore[import-untyped]
except ImportError:
logger.debug("duckduckgo-search not installed; using httpx fallback")
return self._web_search_fallback(query, num_results)
# Import exception types (package versions differ on exact names)
try:
from duckduckgo_search.exceptions import ( # type: ignore[import-untyped]
RatelimitException,
DuckDuckGoSearchException,
)
except ImportError:
RatelimitException = Exception # type: ignore[assignment,misc]
DuckDuckGoSearchException = Exception # type: ignore[assignment,misc]
last_exc: Exception | None = None
for attempt in range(3):
try:
+27 -34
View File
@@ -191,6 +191,21 @@ class TestSearchSearxng:
params = call_kwargs[1].get("params", call_kwargs[0][1] if len(call_kwargs[0]) > 1 else {})
assert params.get("time_range") == "month"
@patch("gitea.tools.research_tools.httpx.Client")
def test_passes_basic_auth_if_configured(
self, mock_cls: MagicMock, tools: ResearchTools
) -> None:
mock_cls.return_value.__enter__.return_value.get.return_value = (
self._make_searxng_response([])
)
with patch("gitea.tools.research_tools._SEARXNG_USERNAME", "user"), \
patch("gitea.tools.research_tools._SEARXNG_PASSWORD", "pass"):
tools._search_searxng("q", num_results=5)
mock_cls.assert_called_once()
kwargs = mock_cls.call_args[1]
assert kwargs.get("auth") == ("user", "pass")
@patch("gitea.tools.research_tools.httpx.Client")
def test_web_search_uses_searxng_first(
self, mock_cls: MagicMock, tools: ResearchTools
@@ -228,7 +243,8 @@ class TestWebSearch:
self, _mock_sleep: MagicMock, tools: ResearchTools
) -> None:
mock_cls = MagicMock(return_value=self._mock_ddgs(self._make_ddgs_result()))
with patch.dict("sys.modules", {"duckduckgo_search": MagicMock(DDGS=mock_cls)}):
with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \
patch("gitea.tools.research_tools.DDGS", mock_cls):
result = tools.web_search("python httpx")
assert "[1]" in result
assert "foo.com" in result
@@ -238,14 +254,16 @@ class TestWebSearch:
self, _mock_sleep: MagicMock, tools: ResearchTools
) -> None:
mock_cls = MagicMock(return_value=self._mock_ddgs([]))
with patch.dict("sys.modules", {"duckduckgo_search": MagicMock(DDGS=mock_cls)}):
with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \
patch("gitea.tools.research_tools.DDGS", mock_cls):
result = tools.web_search("xyzzy-not-real")
assert "No results" in result
def test_clamps_num_results_max(self, tools: ResearchTools) -> None:
ddgs_mock = self._mock_ddgs(self._make_ddgs_result())
mock_cls = MagicMock(return_value=ddgs_mock)
with patch.dict("sys.modules", {"duckduckgo_search": MagicMock(DDGS=mock_cls)}):
with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \
patch("gitea.tools.research_tools.DDGS", mock_cls):
tools.web_search("q", num_results=999)
# DDGS.text should be called with max_results clamped to 20
ddgs_mock.text.assert_called_once()
@@ -255,7 +273,8 @@ class TestWebSearch:
def test_clamps_num_results_min(self, tools: ResearchTools) -> None:
ddgs_mock = self._mock_ddgs(self._make_ddgs_result())
mock_cls = MagicMock(return_value=ddgs_mock)
with patch.dict("sys.modules", {"duckduckgo_search": MagicMock(DDGS=mock_cls)}):
with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \
patch("gitea.tools.research_tools.DDGS", mock_cls):
tools.web_search("q", num_results=0)
_, kwargs = ddgs_mock.text.call_args
assert kwargs.get("max_results", 0) >= 1
@@ -271,40 +290,14 @@ class TestWebSearch:
ddgs_mock.__exit__ = MagicMock(return_value=False)
ddgs_mock.text.side_effect = rate_exc
mock_module = MagicMock()
mock_module.DDGS = MagicMock(return_value=ddgs_mock)
# Make RatelimitException match our rate_exc type
mock_module.exceptions.RatelimitException = type(rate_exc)
mock_module.exceptions.DuckDuckGoSearchException = ValueError
with patch.dict("sys.modules", {
"duckduckgo_search": mock_module,
"duckduckgo_search.exceptions": mock_module.exceptions,
}):
with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \
patch("gitea.tools.research_tools.DDGS", return_value=ddgs_mock), \
patch("gitea.tools.research_tools.RatelimitException", type(rate_exc)), \
patch("gitea.tools.research_tools.DuckDuckGoSearchException", ValueError):
result = tools.web_search("q")
assert isinstance(result, str) # Returns error string, not raise
def test_fallback_when_ddgs_not_installed(self, tools: ResearchTools) -> None:
"""When duckduckgo-search is not installed, uses httpx HTML fallback."""
import builtins
real_import = builtins.__import__
def mock_import(name: str, *args, **kwargs): # type: ignore[no-untyped-def]
if name == "duckduckgo_search":
raise ImportError("mocked missing")
return real_import(name, *args, **kwargs)
mock_response = MagicMock()
mock_response.text = ""
mock_response.raise_for_status = MagicMock()
with patch("builtins.__import__", side_effect=mock_import):
with patch("gitea.tools.research_tools.httpx.Client") as mock_cls:
mock_cls.return_value.__enter__.return_value.get.return_value = mock_response
result = tools.web_search("test")
assert isinstance(result, str)
# ------------------------------------------------------------------ #