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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,8 @@ ANTHROPIC_API_KEY=
# [optional] Required only by the built-in web_search tool (Exa), which stays
# disabled until a key is present.
EXA_API_KEY=

# [optional] You.com API key. When set, agents can use You.com for web search
# (higher rate limits than the free keyless tier). Get one at
# https://you.com/platform/api-keys . Leave blank to use the keyless free tier.
YDC_API_KEY=
2 changes: 2 additions & 0 deletions ms_agent/tools/search/search_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class SearchEngineType(enum.Enum):
SERPAPI = 'serpapi'
ARXIV = 'arxiv'
TAVILY = 'tavily'
YOCOM = 'youcom'


# Mapping from engine type to tool name
Expand All @@ -25,6 +26,7 @@ class SearchEngineType(enum.Enum):
'serpapi': 'serpapi_search',
'arxiv': 'arxiv_search',
'tavily': 'tavily_search',
'youcom': 'youcom_search',
}


Expand Down
51 changes: 51 additions & 0 deletions ms_agent/tools/search/search_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from ms_agent.tools.search.exa import ExaSearchRequest
from ms_agent.tools.search.search_base import SearchEngineType, SearchRequest
from ms_agent.tools.search.serpapi.schema import SerpApiSearchRequest
from ms_agent.tools.search.youcom.schema import YouSearchRequest


class SearchRequestGenerator:
Expand Down Expand Up @@ -256,6 +257,54 @@ def create_request(self,
return ArxivSearchRequest(**search_request_d)


class YouSearchRequestGenerator(SearchRequestGenerator):
"""Minimal generator for You.com search requests."""

def get_args_template(self) -> str:
return '{"query": "xxx", "num_results": 5}'

def get_json_schema(self,
num_queries: int,
is_strict: bool = True) -> Dict[str, Any]:
return {
'name': 'search_requests',
'strict': is_strict,
'schema': {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'query': {
'type': 'string',
'description': (
'An English search query for general web search. '
'Keep queries concise and use natural language.'),
},
'num_results': {
'type': 'integer',
'description': 'Number of results (1-10).',
},
'research_goal': {
'type': 'string',
'description': 'The goal of the research and additional directions',
}
},
'required': ['query', 'num_results', 'research_goal']
},
'description': f'List of web search queries, max of {num_queries}'
}
}

def get_rewrite_prompt(self) -> str:
return (
"Rewrite the user's request into a search query for You.com web search."
)

def create_request(self,
search_request_d: Dict[str, Any]) -> YouSearchRequest:
return YouSearchRequest(**search_request_d)


def get_search_request_generator(engine_type: SearchEngineType,
user_prompt: str) -> SearchRequestGenerator:
"""
Expand All @@ -277,5 +326,7 @@ def get_search_request_generator(engine_type: SearchEngineType,
return SerpApiSearchRequestGenerator(user_prompt)
elif engine_type == SearchEngineType.ARXIV:
return ArxivSearchRequestGenerator(user_prompt)
elif engine_type == SearchEngineType.YOCOM:
return YouSearchRequestGenerator(user_prompt)
else:
raise ValueError(f'Unsupported search engine type: {engine_type}')
9 changes: 9 additions & 0 deletions ms_agent/tools/search/websearch_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@ def get_search_engine_class(engine_type: str) -> Type[SearchEngine]:
elif engine_type == 'tavily':
from ms_agent.tools.search.tavily import TavilySearch
return TavilySearch
elif engine_type in ('youcom', 'you'):
from ms_agent.tools.search.youcom import YouSearch
return YouSearch
else:
logger.warning(
f"Unknown search engine '{engine_type}', falling back to arxiv")
Expand Down Expand Up @@ -337,6 +340,12 @@ def get_search_engine(engine_type: str,
api_key=api_key or os.getenv('TAVILY_API_KEY'),
request_timeout=float(kwargs.get('request_timeout', 120.0)),
)
elif engine_type in ('youcom', 'you'):
from ms_agent.tools.search.youcom import YouSearch
return YouSearch(
api_key=api_key or os.getenv('YDC_API_KEY'),
request_timeout=float(kwargs.get('request_timeout', 60.0)),
)
else:
logger.warning(
f"Unknown search engine '{engine_type}', falling back to arxiv")
Expand Down
4 changes: 4 additions & 0 deletions ms_agent/tools/search/youcom/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
from ms_agent.tools.search.youcom.search import YouSearch

__all__ = ['YouSearch']
92 changes: 92 additions & 0 deletions ms_agent/tools/search/youcom/http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
"""Minimal HTTP JSON client for You.com REST API (stdlib only)."""
import json
from typing import Any, Dict, Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen


class YouHTTPError(RuntimeError):
"""A You.com call that failed, with structured error fields."""

def __init__(self,
message: str,
*,
status: Optional[int] = None,
code: str = '',
retry_after: Optional[int] = None,
detail: Any = None):
super().__init__(message)
self.status = status
self.code = code
self.retry_after = retry_after
self.detail = detail

@property
def is_quota(self) -> bool:
return self.status == 429 or 'quota' in self.code.lower()

@property
def is_auth(self) -> bool:
return self.status in (401, 403)


def _ssl_context():
try:
import certifi
import ssl
return ssl.create_default_context(cafile=certifi.where())
except Exception:
return None


def get_json(
url: str,
params: Optional[Dict[str, str]] = None,
*,
headers: Optional[Dict[str, str]] = None,
timeout: float = 60.0,
) -> Dict[str, Any]:
"""GET a JSON resource."""
if params:
import urllib.parse
qs = urllib.parse.urlencode(params, doseq=True)
url = f'{url}?{qs}'
merged = {'Accept': 'application/json'}
merged.update(headers or {})
req = Request(url, method='GET', headers=merged)
try:
with urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
raw = resp.read().decode('utf-8', errors='replace')
if not raw.strip():
return {}
return json.loads(raw)
except HTTPError as e:
err_body = ''
try:
err_body = e.read().decode('utf-8', errors='replace')
except Exception:
pass
detail = {}
try:
detail = json.loads(err_body) if err_body else {}
except json.JSONDecodeError:
detail = {'raw': err_body}
code = str(detail.get('code', ''))
msg = str(detail.get('message', '')) or str(detail.get('error', ''))
ra = detail.get('retry_after')
if isinstance(ra, (int, float)):
ra = int(ra)
else:
try:
ra = int(e.headers.get('retry-after')) if e.headers else None
except (TypeError, ValueError, AttributeError):
ra = None
raise YouHTTPError(
f'You.com HTTP {e.code}: {msg or detail}',
status=e.code,
code=code,
retry_after=ra,
detail=detail) from e
except URLError as e:
raise YouHTTPError(f'You.com network error: {e}') from e
60 changes: 60 additions & 0 deletions ms_agent/tools/search/youcom/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional


@dataclass
class YouSearchRequest:
"""You.com POST /search body."""

query: str
num_results: int = 10
search_type: str = 'search' # search | news | knowledge

def to_api_body(self) -> Dict[str, Any]:
return {
'query': self.query,
'num_results': max(1, min(20, int(self.num_results))),
}


@dataclass
class YouSearchResult:
"""Parsed You.com /search JSON."""

query: str
arguments: Dict[str, Any]
response: Dict[str, Any]

def to_list(self) -> List[Dict[str, Any]]:
if not self.response:
return []
# You.com v1/search returns {'results': [...]} or
# {'response': {'results': [...]}} or {'hits': [...]}
results = (
self.response.get('results')
or (self.response.get('response') or {}).get('results')
or self.response.get('hits')
or []
)
if isinstance(results, dict):
results = results.get('results') or []
rows: List[Dict[str, Any]] = []
for r in results:
url = r.get('url') or ''
snippet = (r.get('snippet') or r.get('description') or '').strip()
title = r.get('title') or ''
rows.append({
'url': url,
'id': url,
'title': title,
'highlights': None,
'highlight_scores': None,
'summary': snippet,
'markdown': (r.get('markdown') or r.get('contents') or
None),
})
return rows

def extra_response_fields(self) -> Dict[str, Any]:
return {}
107 changes: 107 additions & 0 deletions ms_agent/tools/search/youcom/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Copyright (c) ModelScope Contributors. All rights reserved.
import os
from typing import TYPE_CHECKING, Any, Optional

from ms_agent.tools.search.search_base import SearchEngine, SearchEngineType
from ms_agent.tools.search.youcom.http import YouHTTPError, get_json
from ms_agent.tools.search.youcom.schema import (YouSearchRequest,
YouSearchResult)
from ms_agent.utils.logger import get_logger

if TYPE_CHECKING:
from ms_agent.llm.utils import Tool

logger = get_logger()

#: Authenticated search endpoint (use with YDC_API_KEY).
YOU_SEARCH_URL = 'https://api.you.com/v1/search'
#: Keyless search endpoint — no API key required, rate limited (~100 req/day).
YOU_KEYLESS_URL = 'https://api.you.com/v1/agents/search'


class YouSearch(SearchEngine):
"""You.com Search — keyed or keyless web search for AI agents.

Works with an API key (``YDC_API_KEY``) for higher rate limits, or falls
back to the free keyless tier when no key is set — no signup required.
"""

engine_type = SearchEngineType.YOCOM

def __init__(
self,
api_key: Optional[str] = None,
request_timeout: float = 60.0,
):
self._api_key = api_key or os.getenv('YDC_API_KEY') or ''
self._request_timeout = float(request_timeout)

@property
def keyless(self) -> bool:
return not self._api_key

def _search_url(self) -> str:
return YOU_KEYLESS_URL if self.keyless else YOU_SEARCH_URL

def _headers(self) -> dict:
if self.keyless:
return {}
return {'Authorization': f'Bearer {self._api_key}'}

def search(self, search_request: YouSearchRequest) -> YouSearchResult:
params = search_request.to_api_body()
data = get_json(
self._search_url(),
params=params,
headers=self._headers(),
timeout=self._request_timeout,
)
return YouSearchResult(
query=search_request.query,
arguments={'query': search_request.query,
'num_results': search_request.num_results},
response=data,
)

@classmethod
def get_tool_definition(cls, server_name: str = 'web_search') -> 'Tool':
from ms_agent.llm.utils import Tool
return Tool(
tool_name=cls.get_tool_name(),
server_name=server_name,
description=(
'Search the web using You.com. Returns ranked web search '
'results with titles, URLs, and snippets. '
'Works without an API key on the free keyless tier.'
),
parameters={
'type': 'object',
'properties': {
'query': {
'type': 'string',
'description': 'The search query.',
},
'num_results': {
'type': 'integer',
'minimum': 1,
'maximum': 10,
'description': (
'Number of search results to return. Default 5.'),
},
},
'required': ['query'],
},
)

@classmethod
def build_request_from_args(cls, **kwargs: Any) -> YouSearchRequest:
num = kwargs.get('num_results', 5)
try:
num = int(num)
except (TypeError, ValueError):
num = 5
return YouSearchRequest(
query=kwargs['query'],
num_results=num,
search_type=kwargs.get('search_type', 'search'),
)
Loading