Connor Adams
Add logfire and duckduckgo
ef01a34
raw
history blame
4.49 kB
"""
safe_duck_tool.py
A resilient, family-friendly DuckDuckGo search Tool for Pydantic-AI.
"""
from __future__ import annotations
import functools
import time
from dataclasses import dataclass
from typing_extensions import TypedDict
import anyio
import anyio.to_thread
from duckduckgo_search import DDGS, exceptions
from pydantic import TypeAdapter
from pydantic_ai.tools import Tool
# ──────────────────────────────────────────────────────────────────────────
# 1. Types
# ──────────────────────────────────────────────────────────────────────────
class DuckDuckGoResult(TypedDict):
title: str
href: str
body: str
duckduckgo_ta = TypeAdapter(list[DuckDuckGoResult])
# ──────────────────────────────────────────────────────────────────────────
# 2. Search wrapper with cache + back-off
# ──────────────────────────────────────────────────────────────────────────
@functools.lru_cache(maxsize=512)
def _safe_search(
query: str,
*,
ddgs_constructor_kwargs_tuple: tuple,
safesearch: str,
max_results: int | None,
retries: int = 5,
) -> list[dict[str, str]]:
wait = 1
for _ in range(retries):
try:
ddgs = DDGS(**dict(ddgs_constructor_kwargs_tuple))
return list(
ddgs.text(query, safesearch=safesearch, max_results=max_results)
)
except exceptions.RatelimitException as e:
time.sleep(getattr(e, "retry_after", wait))
wait = min(wait * 2, 30)
raise RuntimeError("DuckDuckGo kept rate-limiting after multiple attempts")
# ──────────────────────────────────────────────────────────────────────────
# 3. Tool implementation
# ──────────────────────────────────────────────────────────────────────────
@dataclass
class _SafeDuckToolImpl:
ddgs_constructor_kwargs: dict # Renamed from client_kwargs
safesearch: str # Added to store safesearch setting
max_results: int | None
async def __call__(self, query: str) -> list[DuckDuckGoResult]:
search = functools.partial(
_safe_search,
# Convert dict to sorted tuple of items to make it hashable
ddgs_constructor_kwargs_tuple=tuple(
sorted(self.ddgs_constructor_kwargs.items())
),
safesearch=self.safesearch, # Pass stored safesearch
max_results=self.max_results,
)
results = await anyio.to_thread.run_sync(search, query)
# validate & coerce with Pydantic
return duckduckgo_ta.validate_python(results)
def safe_duckduckgo_search_tool(
*,
safesearch: str = "moderate", # "on" | "moderate" | "off"
timeout: int = 15,
max_results: int | None = None,
proxy: str | None = None, # e.g. "socks5h://user:pw@host:1080"
) -> Tool:
"""
Create a resilient, Safe-Search-enabled DuckDuckGo search Tool.
Drop-in replacement for `pydantic_ai.common_tools.duckduckgo.duckduckgo_search_tool`.
"""
# Arguments for DDGS constructor
ddgs_constructor_kwargs = dict(
timeout=timeout,
proxy=proxy,
)
# Arguments for ddgs.text() method are handled separately (safesearch, max_results)
impl = _SafeDuckToolImpl(
ddgs_constructor_kwargs=ddgs_constructor_kwargs,
safesearch=safesearch,
max_results=max_results,
)
return Tool(
impl.__call__,
name="safe_duckduckgo_search",
description=(
"DuckDuckGo web search with Safe Search, automatic back-off, and "
"LRU caching. Pass a plain-text query; returns a list of results."
),
)