diff --git a/AgentCrew/modules/gui/widgets/configs/global_settings.py b/AgentCrew/modules/gui/widgets/configs/global_settings.py index 16ce2e77..ed87930d 100644 --- a/AgentCrew/modules/gui/widgets/configs/global_settings.py +++ b/AgentCrew/modules/gui/widgets/configs/global_settings.py @@ -90,6 +90,11 @@ class SettingsTab(QWidget): "key_name": "TAVILY_API_KEY", "placeholder": "e.g., tvly-...", }, + { + "label": "You.com API Key (optional):", + "key_name": "YDC_API_KEY", + "placeholder": "e.g., ydc-sk-...", + }, { "label": "Voyage API Key:", "key_name": "VOYAGE_API_KEY", diff --git a/AgentCrew/modules/web_search/__init__.py b/AgentCrew/modules/web_search/__init__.py index a2bca500..04edc364 100644 --- a/AgentCrew/modules/web_search/__init__.py +++ b/AgentCrew/modules/web_search/__init__.py @@ -1,5 +1,7 @@ from AgentCrew.modules.web_search.service import TavilySearchService +from AgentCrew.modules.web_search.you_service import YouSearchService __all__ = [ "TavilySearchService", + "YouSearchService", ] diff --git a/AgentCrew/modules/web_search/tool.py b/AgentCrew/modules/web_search/tool.py index 28cd25db..b541c088 100644 --- a/AgentCrew/modules/web_search/tool.py +++ b/AgentCrew/modules/web_search/tool.py @@ -301,6 +301,7 @@ def register(service_instance=None, agent=None): agent: Agent instance to register with directly (optional) """ from AgentCrew.modules.tools.registration import register_tool + from AgentCrew.modules.web_search.you_service import YouSearchService register_tool( get_web_search_tool_definition, @@ -308,6 +309,9 @@ def register(service_instance=None, agent=None): service_instance, agent, ) + if isinstance(service_instance, YouSearchService): + # You.com provides search only; extract and crawl still need Tavily. + return register_tool( get_web_extract_tool_definition, get_web_extract_tool_handler, diff --git a/AgentCrew/modules/web_search/you_service.py b/AgentCrew/modules/web_search/you_service.py new file mode 100644 index 00000000..4792a546 --- /dev/null +++ b/AgentCrew/modules/web_search/you_service.py @@ -0,0 +1,115 @@ +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +from dotenv import load_dotenv + +YOU_FREE_SEARCH_URL = "https://api.you.com/v1/agents/search" +YOU_KEYED_SEARCH_URL = "https://ydc-index.io/v1/search" +YOU_USER_AGENT = "youdotcom-integration/saigontechnology-agentcrew" + + +class YouSearchService: + """Web search through the You.com Search API. + + Works without an API key on the free tier (100 searches a day per IP). + Set YDC_API_KEY to use the keyed endpoint with higher limits. + Only search is supported; page extraction and crawling still need Tavily. + """ + + def __init__(self, timeout: float = 20.0): + load_dotenv() + self.api_key = (os.getenv("YDC_API_KEY") or "").strip() or None + self.timeout = timeout + + def _request(self, query: str, count: int) -> dict[str, Any]: + headers = {"User-Agent": YOU_USER_AGENT, "Accept": "application/json"} + if self.api_key: + headers["X-API-Key"] = self.api_key + headers["Content-Type"] = "application/json" + data = json.dumps({"query": query, "count": count}).encode("utf-8") + req = urllib.request.Request( + YOU_KEYED_SEARCH_URL, data=data, headers=headers, method="POST" + ) + else: + params = urllib.parse.urlencode({"query": query, "count": count}) + req = urllib.request.Request( + f"{YOU_FREE_SEARCH_URL}?{params}", headers=headers, method="GET" + ) + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + + def search( + self, + query: str, + max_results: int = 5, + include_domains: list[str] | None = None, + **_: Any, + ) -> dict[str, Any]: + """Search the web and return results in the same shape as Tavily.""" + count = max(1, min(int(max_results or 5), 20)) + if include_domains: + sites = " OR ".join(f"site:{d}" for d in include_domains) + query = f"{query} ({sites})" + for attempt in range(2): + try: + payload = self._request(query, count) + break + except urllib.error.HTTPError as e: + if e.code == 429 and attempt == 0: + retry_after = e.headers.get("Retry-After", "") + if retry_after.isdigit() and int(retry_after) <= 10: + time.sleep(int(retry_after)) + continue + return {"error": self._describe_http_error(e.code)} + except Exception as e: + return {"error": f"You.com search failed: {e!s}"} + + web = (payload.get("results") or {}).get("web") or [] + results = [] + for item in web[:count]: + snippets = item.get("snippets") or [] + content = " ".join(s for s in snippets if s) or item.get("description", "") + results.append( + { + "title": item.get("title", ""), + "url": item.get("url", ""), + "content": content, + "page_age": item.get("page_age"), + } + ) + return {"query": query, "results": results} + + def _describe_http_error(self, code: int) -> str: + if code == 401: + return "You.com search: invalid YDC_API_KEY (401)" + if code == 402: + return "You.com search: API credits exhausted (402)" + if code == 403: + return "You.com search: key not allowed for this endpoint (403)" + if code == 429: + if self.api_key: + return "You.com search: rate limited (429), try again shortly" + return ( + "You.com search: free daily limit reached (429). " + "Set YDC_API_KEY for higher limits." + ) + return f"You.com search: HTTP {code}" + + def format_search_results(self, results: dict[str, Any]) -> str: + if "error" in results: + return f"Search error: {results['error']}" + items = results.get("results") or [] + if not items: + return "0 results" + lines = [f"{len(items)} results"] + for i, r in enumerate(items, 1): + lines.append(f"{i}. {r.get('title') or 'No title'}") + lines.append(r.get("url") or "No URL") + if r.get("content"): + lines.append(r["content"]) + return "\n".join(lines) diff --git a/AgentCrew/setup.py b/AgentCrew/setup.py index c3d795f9..2003f2a0 100644 --- a/AgentCrew/setup.py +++ b/AgentCrew/setup.py @@ -254,6 +254,7 @@ def load_api_keys_from_config(self) -> None: "GITHUB_COPILOT_API_KEY", "FIREWORKS_API_KEY", "TAVILY_API_KEY", + "YDC_API_KEY", "VOYAGE_API_KEY", "ELEVENLABS_API_KEY", ] @@ -476,16 +477,17 @@ def setup_services( from AgentCrew.modules.web_search import TavilySearchService search_service = TavilySearchService() - except ValueError as e: + except ValueError: + # No Tavily key: fall back to You.com search, which works without a key. + from AgentCrew.modules.web_search import YouSearchService + + search_service = YouSearchService() click.echo( - f"\u26a0\ufe0f Web search tools not available: {e!s}\n" - " Without web search, agents cannot look up current information, recent events,\n" - " documentation, or real-time data from the internet.\n" - " \U0001f4a1 Get a free Tavily API key (no credit card, 1,000 calls/month) at:\n" - " https://app.tavily.com/\n" - " Then set it via the TAVILY_API_KEY environment variable or config.json." + "\u2139\ufe0f TAVILY_API_KEY not set, using You.com for web search " + "(free tier, no key needed).\n" + " Set YDC_API_KEY for higher limits, or TAVILY_API_KEY to enable\n" + " page extraction and crawling." ) - search_service = None except Exception as e: click.echo(f"\u26a0\ufe0f Web search tools not available: {e!s}") search_service = None diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 0954f270..21786a34 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -56,6 +56,7 @@ the full file shape. "GITHUB_COPILOT_API_KEY": "YOUR_GITHUB_COPILOT_API_KEY", "FIREWORKS_API_KEY": "YOUR_FIREWORKS_API_KEY", "TAVILY_API_KEY": "YOUR_TAVILY_API_KEY", + "YDC_API_KEY": "YOUR_YDC_API_KEY", "VOYAGE_API_KEY": "YOUR_VOYAGE_API_KEY", "ELEVENLABS_API_KEY": "YOUR_ELEVENLABS_API_KEY" }, @@ -130,6 +131,7 @@ They can also be set directly as environment variables. | `GITHUB_COPILOT_API_KEY` | GitHub Copilot provider credential. Use `agentcrew copilot-auth` for the supported Copilot login flow. | | `FIREWORKS_API_KEY` | Fireworks AI provider credential. | | `TAVILY_API_KEY` | Tavily web-search feature credential; not an LLM provider credential. | +| `YDC_API_KEY` | Optional You.com web-search credential. When `TAVILY_API_KEY` is not set, `search_web` falls back to You.com, which works without a key on the free tier (100 searches a day). The key raises that limit. Page extraction and crawling still need Tavily. | | `VOYAGE_API_KEY` | Voyage embedding credential for the normal memory path. | | `ELEVENLABS_API_KEY` | ElevenLabs voice-synthesis credential. | diff --git a/tests/test_you_search.py b/tests/test_you_search.py new file mode 100644 index 00000000..117a9384 --- /dev/null +++ b/tests/test_you_search.py @@ -0,0 +1,97 @@ +import io +import json +import unittest +import urllib.error +from unittest.mock import patch + +from AgentCrew.modules.web_search.tool import get_web_search_tool_handler +from AgentCrew.modules.web_search.you_service import ( + YOU_FREE_SEARCH_URL, + YOU_KEYED_SEARCH_URL, + YOU_USER_AGENT, + YouSearchService, +) + +SAMPLE = { + "results": { + "web": [ + { + "url": "https://docs.python.org/3/library/asyncio.html", + "title": "asyncio", + "description": "Asynchronous I/O", + "snippets": ["asyncio is a library", "to write concurrent code"], + "page_age": "2026-01-01T00:00:00", + }, + {"url": "https://example.com", "title": "Example", "description": "desc"}, + ] + } +} + + +class _Resp(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + +def _ok(payload=SAMPLE): + return _Resp(json.dumps(payload).encode()) + + +class YouSearchServiceTest(unittest.IsolatedAsyncioTestCase): + def _service(self, key=None): + with ( + patch.dict("os.environ", {"YDC_API_KEY": key or ""}), + patch("AgentCrew.modules.web_search.you_service.load_dotenv"), + ): + return YouSearchService() + + def test_keyless_uses_free_get_endpoint(self): + svc = self._service() + with patch("urllib.request.urlopen", return_value=_ok()) as m: + result = svc.search("python asyncio", max_results=2) + req = m.call_args[0][0] + self.assertEqual(req.get_method(), "GET") + self.assertTrue(req.full_url.startswith(YOU_FREE_SEARCH_URL + "?")) + self.assertIn("count=2", req.full_url) + self.assertEqual(req.get_header("User-agent"), YOU_USER_AGENT) + self.assertIsNone(req.get_header("X-api-key")) + self.assertEqual(len(result["results"]), 2) + self.assertEqual( + result["results"][0]["content"], + "asyncio is a library to write concurrent code", + ) + self.assertEqual(result["results"][1]["content"], "desc") + + def test_key_uses_keyed_post_endpoint(self): + svc = self._service("test-key") + with patch("urllib.request.urlopen", return_value=_ok()) as m: + svc.search("python", max_results=3) + req = m.call_args[0][0] + self.assertEqual(req.get_method(), "POST") + self.assertEqual(req.full_url, YOU_KEYED_SEARCH_URL) + self.assertEqual(req.get_header("X-api-key"), "test-key") + self.assertEqual(json.loads(req.data), {"query": "python", "count": 3}) + + def test_http_errors_are_reported(self): + svc = self._service() + err = urllib.error.HTTPError(YOU_FREE_SEARCH_URL, 429, "Too Many", {}, None) + with patch("urllib.request.urlopen", side_effect=err): + result = svc.search("x") + self.assertIn("YDC_API_KEY", result["error"]) + + async def test_handler_formats_results(self): + svc = self._service() + handler = get_web_search_tool_handler(svc) + with patch("urllib.request.urlopen", return_value=_ok()): + text = await handler( + query="python asyncio", search_depth="basic", topic="general" + ) + self.assertIn("2 results", text) + self.assertIn("https://docs.python.org/3/library/asyncio.html", text) + + +if __name__ == "__main__": + unittest.main()