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
5 changes: 5 additions & 0 deletions AgentCrew/modules/gui/widgets/configs/global_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions AgentCrew/modules/web_search/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from AgentCrew.modules.web_search.service import TavilySearchService
from AgentCrew.modules.web_search.you_service import YouSearchService

__all__ = [
"TavilySearchService",
"YouSearchService",
]
4 changes: 4 additions & 0 deletions AgentCrew/modules/web_search/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,13 +301,17 @@ 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,
get_web_search_tool_handler,
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,
Expand Down
115 changes: 115 additions & 0 deletions AgentCrew/modules/web_search/you_service.py
Original file line number Diff line number Diff line change
@@ -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)
18 changes: 10 additions & 8 deletions AgentCrew/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -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. |

Expand Down
97 changes: 97 additions & 0 deletions tests/test_you_search.py
Original file line number Diff line number Diff line change
@@ -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()