重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。

本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-25 01:24:23 +08:00
parent ba3836f00f
commit 4a23b715a2
498 changed files with 2760 additions and 2200 deletions

View file

@ -0,0 +1,29 @@
"""通识专家工具清单。"""
from oclaw.runtime.tools.base import ToolSpec
def system_info_tool() -> ToolSpec:
from .system_info import system_info_tool as factory
return factory()
def geo_info_tool() -> ToolSpec:
from .geo_info import geo_info_tool as factory
return factory()
def weather_tool() -> ToolSpec:
from .weather import weather_tool as factory
return factory()
def web_search_tool() -> ToolSpec:
from .web_search import web_search_tool as factory
return factory()
__all__ = ["system_info_tool", "geo_info_tool", "weather_tool", "web_search_tool"]

View file

@ -0,0 +1,66 @@
"""系统工具共用 HTTP 辅助函数(Nominatim 逆地理编码与 ipapi.co)。"""
from __future__ import annotations
from typing import Any
import httpx
NOMINATIM_REQUEST_HEADERS = {"User-Agent": "OpsAssistant/1.0 (internal tool)"}
DEFAULT_HTTP_TIMEOUT = 10.0
def nominatim_reverse(
client: httpx.Client,
lat: float,
lon: float,
*,
accept_language: str = "en",
) -> dict[str, Any]:
"""调用 Nominatim 逆地理编码并返回解析后的 JSON,失败时返回空字典。"""
try:
r = client.get(
"https://nominatim.openstreetmap.org/reverse",
params={"lat": lat, "lon": lon, "format": "json", "accept-language": accept_language},
headers=NOMINATIM_REQUEST_HEADERS,
)
r.raise_for_status()
data = r.json()
return data if isinstance(data, dict) else {}
except Exception:
return {}
def ipapi_approximate_location(client: httpx.Client) -> dict[str, Any] | None:
try:
ip_resp = client.get("https://ipapi.co/json/")
ip_resp.raise_for_status()
ip_data = ip_resp.json()
lat = ip_data.get("latitude")
lon = ip_data.get("longitude")
if lat is None or lon is None:
return None
lat_f = float(lat)
lon_f = float(lon)
geo = nominatim_reverse(client, lat_f, lon_f)
display_name = geo.get("display_name") if geo else None
if not display_name or not str(display_name).strip():
parts = [ip_data.get("city"), ip_data.get("region"), ip_data.get("country_name")]
display_name = ", ".join(str(p) for p in parts if p)
if not display_name:
display_name = f"Approximate ({lat_f:.4f}, {lon_f:.4f})"
return {
"latitude": lat_f,
"longitude": lon_f,
"display_name": str(display_name).strip(),
"ip": ip_data.get("ip"),
"city": ip_data.get("city"),
"region": ip_data.get("region"),
"country_name": ip_data.get("country_name"),
"nominatim": geo,
}
except Exception:
return None
__all__ = ["DEFAULT_HTTP_TIMEOUT", "NOMINATIM_REQUEST_HEADERS", "ipapi_approximate_location", "nominatim_reverse"]

View file

@ -0,0 +1,78 @@
from __future__ import annotations
import httpx
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from .geo_http import DEFAULT_HTTP_TIMEOUT, ipapi_approximate_location, nominatim_reverse
def _reverse_geocode(lat: float, lon: float) -> dict[str, Any]:
with httpx.Client(timeout=DEFAULT_HTTP_TIMEOUT) as client:
return nominatim_reverse(client, lat, lon)
def geo_info_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
lat = args.get("latitude")
lon = args.get("longitude")
if lat is None or lon is None:
return {"ok": False, "error": "latitude and longitude are required"}
try:
lat_f = float(lat)
lon_f = float(lon)
except (TypeError, ValueError):
return {"ok": False, "error": "latitude and longitude must be numbers"}
data = _reverse_geocode(lat_f, lon_f)
if not data or "error" in data:
error_msg = data.get("error") if data else "Unknown error"
return {"ok": False, "error": error_msg}
return {"ok": True, "address": data.get("display_name"), "details": data.get("address"), "latitude": lat_f, "longitude": lon_f}
return ToolSpec(
name="reverse_geocode",
description="Reverse geocode: get a human-readable address from latitude and longitude.",
parameters={
"type": "object",
"properties": {
"latitude": {"type": "number", "description": "Latitude in decimal degrees."},
"longitude": {"type": "number", "description": "Longitude in decimal degrees."},
},
"required": ["latitude", "longitude"],
"additionalProperties": False,
},
handler=handler,
)
def system_location_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
with httpx.Client(timeout=DEFAULT_HTTP_TIMEOUT) as client:
loc = ipapi_approximate_location(client)
if not loc:
return {"ok": False, "error": "Could not detect coordinates for this network"}
geo = loc.get("nominatim") or {}
return {
"ok": True,
"latitude": loc["latitude"],
"longitude": loc["longitude"],
"address": loc["display_name"],
"ip": loc.get("ip"),
"city": loc.get("city"),
"region": loc.get("region"),
"country": loc.get("country_name"),
"details": geo.get("address") if geo else None,
}
except Exception as e:
return {"ok": False, "error": f"Failed to detect location: {e}"}
return ToolSpec(
name="get_system_location",
description="Detect this machine's public IP and approximate location (coordinates and address).",
parameters={"type": "object", "properties": {}, "additionalProperties": False},
handler=handler,
)
__all__ = ["geo_info_tool", "system_location_tool"]

View file

@ -0,0 +1,129 @@
from __future__ import annotations
import base64
import io
import os
from typing import Any
from PIL import Image
from oclaw.platform.files.attachment_assets import AttachmentAssetStore
from oclaw.runtime.tools.base import ToolSpec
def image_edit_tool() -> ToolSpec:
"""Edit an uploaded image using OpenAI Images API.
Input image is referenced by attachment_id (disk-backed asset store).
Output is saved back to the asset store and returned as attachment_id.
"""
def handler(args: dict[str, Any]) -> dict[str, Any]:
attachment_id = str(args.get("attachment_id") or "").strip()
instruction = str(args.get("instruction") or "").strip()
model = str(args.get("model") or os.getenv("OPENAI_IMAGE_MODEL") or "gpt-image-1").strip()
if not attachment_id:
return {"ok": False, "error": "attachment_id is required"}
if not instruction:
return {"ok": False, "error": "instruction is required"}
store = AttachmentAssetStore()
blob, meta = store.load_bytes(attachment_id)
if not blob:
return {"ok": False, "error": f"attachment not found: {attachment_id}"}
try:
from openai import OpenAI
except Exception as e:
return {"ok": False, "error": f"openai package is not available: {type(e).__name__}: {e}"}
api_key = (os.getenv("OPENAI_API_KEY") or "").strip()
base_url = (os.getenv("OPENAI_BASE_URL") or "").strip()
if not api_key:
return {"ok": False, "error": "OPENAI_API_KEY is not set"}
client_kwargs: dict[str, Any] = {"api_key": api_key}
if base_url:
client_kwargs["base_url"] = base_url
client = OpenAI(**client_kwargs)
# OpenAI SDK expects a file-like object for edits.
img_file = io.BytesIO(blob)
img_file.name = "input.png" # type: ignore[attr-defined]
b64_out: str | None = None
try:
# Preferred: image edit endpoint (if supported by the gateway/model).
resp = client.images.edit( # type: ignore[attr-defined]
model=model,
image=img_file,
prompt=instruction,
response_format="b64_json",
)
data0 = resp.data[0] if getattr(resp, "data", None) else None
b64_out = getattr(data0, "b64_json", None) if data0 is not None else None
except Exception:
# Fallback: generate a new image from prompt (still returns an image, but not true edit).
try:
resp = client.images.generate( # type: ignore[attr-defined]
model=model,
prompt=instruction,
response_format="b64_json",
)
data0 = resp.data[0] if getattr(resp, "data", None) else None
b64_out = getattr(data0, "b64_json", None) if data0 is not None else None
except Exception as e2:
return {"ok": False, "error": f"image api failed: {type(e2).__name__}: {e2}"}
if not b64_out:
return {"ok": False, "error": "image api returned no b64_json payload"}
try:
out_bytes = base64.b64decode(b64_out.encode("ascii"))
except Exception as e:
return {"ok": False, "error": f"failed to decode image b64: {type(e).__name__}: {e}"}
width = None
height = None
try:
with Image.open(io.BytesIO(out_bytes)) as im:
width, height = im.size
except Exception:
pass
out_meta = store.save_bytes(
out_bytes,
filename=f"edited-{meta.name if meta else 'image'}.png",
mime="image/png",
width=width,
height=height,
)
return {
"ok": True,
"attachment_id": out_meta.attachment_id,
"name": out_meta.name,
"mime": out_meta.mime,
"bytes": out_meta.bytes,
"width": out_meta.width,
"height": out_meta.height,
}
return ToolSpec(
name="image_edit",
description="Edit an uploaded image referenced by attachment_id, returning a new attachment_id.",
parameters={
"type": "object",
"properties": {
"attachment_id": {"type": "string", "description": "Input image attachment id (image_ref)."},
"instruction": {"type": "string", "description": "Edit instruction for the image."},
"model": {"type": "string", "description": "OpenAI image model name (default: gpt-image-1)."},
},
"required": ["attachment_id", "instruction"],
},
handler=handler,
tags=frozenset({"image", "edit"}),
)
__all__ = ["image_edit_tool"]

View file

@ -0,0 +1,33 @@
from __future__ import annotations
import datetime
import time
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
def system_info_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
now = datetime.datetime.now()
utc_now = datetime.datetime.now(datetime.timezone.utc)
timezone_name = time.tzname[0] if time.daylight == 0 else time.tzname[1]
timezone_offset = (now - utc_now.replace(tzinfo=None)).total_seconds() / 3600
return {
"ok": True,
"current_time": now.strftime("%Y-%m-%d %H:%M:%S"),
"timezone": timezone_name,
"timezone_offset": f"UTC{'+' if timezone_offset >= 0 else ''}{timezone_offset:g}",
"timestamp": int(time.time()),
}
return ToolSpec(
name="get_system_time",
description="Return the current local time, timezone name, and UTC offset.",
parameters={"type": "object", "properties": {}, "additionalProperties": False},
handler=handler,
read_only=True,
)
__all__ = ["system_info_tool"]

View file

@ -0,0 +1,150 @@
from __future__ import annotations
from typing import Any
from oclaw.platform.files.tabular_attachment_store import (
aggregate_table,
analyze_table_full_scan,
query_table,
run_table_sql,
)
from oclaw.runtime.tools.base import ToolSpec
def query_tabular_attachment_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
table_id = str(args.get("table_id") or "").strip()
if not table_id:
return {"ok": False, "error": "table_id_required"}
raw_cols = args.get("columns")
cols = [str(x) for x in raw_cols] if isinstance(raw_cols, list) else None
sheet = str(args.get("sheet") or "").strip() or None
where_contains = args.get("where_contains") if isinstance(args.get("where_contains"), dict) else None
aggregate = args.get("aggregate") if isinstance(args.get("aggregate"), dict) else None
if aggregate:
return aggregate_table(
table_id=table_id,
metric=str(aggregate.get("metric") or ""),
target_column=str(aggregate.get("target_column") or "").strip() or None,
group_by=str(aggregate.get("group_by") or "").strip() or None,
where_contains=where_contains,
top_n=int(aggregate.get("top_n") or 20),
sheet=sheet,
)
return query_table(
table_id=table_id,
columns=cols,
limit=int(args.get("limit") or 50),
offset=int(args.get("offset") or 0),
where_contains=where_contains, # {"column":"...", "keyword":"..."}
sheet=sheet,
)
return ToolSpec(
name="query_tabular_attachment",
description="Query rows from a large uploaded table by table_id with optional column selection and keyword filter.",
parameters={
"type": "object",
"properties": {
"table_id": {"type": "string"},
"sheet": {"type": "string"},
"columns": {"type": "array", "items": {"type": "string"}},
"limit": {"type": "integer", "minimum": 1, "maximum": 200},
"offset": {"type": "integer", "minimum": 0},
"where_contains": {
"type": "object",
"properties": {
"column": {"type": "string"},
"keyword": {"type": "string"},
},
"additionalProperties": False,
},
"aggregate": {
"type": "object",
"properties": {
"metric": {"type": "string", "enum": ["count", "sum", "avg"]},
"target_column": {"type": "string"},
"group_by": {"type": "string"},
"top_n": {"type": "integer", "minimum": 1, "maximum": 200},
},
"required": ["metric"],
"additionalProperties": False,
},
},
"required": ["table_id"],
"additionalProperties": False,
},
handler=handler,
read_only=True,
)
def run_tabular_sql_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
table_id = str(args.get("table_id") or "").strip()
sql = str(args.get("sql") or "").strip()
sheet = str(args.get("sheet") or "").strip() or None
if not table_id:
return {"ok": False, "error": "table_id_required"}
return run_table_sql(
table_id=table_id,
sql=sql,
limit=int(args.get("limit") or 200),
sheet=sheet,
)
return ToolSpec(
name="run_tabular_sql",
description="Run a READ-ONLY SQL query against uploaded table by table_id. Only SELECT/WITH allowed.",
parameters={
"type": "object",
"properties": {
"table_id": {"type": "string"},
"sheet": {"type": "string"},
"sql": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 500},
},
"required": ["table_id", "sql"],
"additionalProperties": False,
},
handler=handler,
read_only=True,
)
def analyze_tabular_attachment_full_scan_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
table_id = str(args.get("table_id") or "").strip()
if not table_id:
return {"ok": False, "error": "table_id_required"}
raw_cols = args.get("columns")
cols = [str(x) for x in raw_cols] if isinstance(raw_cols, list) else None
sheet = str(args.get("sheet") or "").strip() or None
return analyze_table_full_scan(
table_id=table_id,
columns=cols,
sheet=sheet,
top_values_limit=int(args.get("top_values_limit") or 3),
)
return ToolSpec(
name="analyze_tabular_attachment_full_scan",
description="Run a full-table scan for selected columns and return concise profiling stats with audit evidence.",
parameters={
"type": "object",
"properties": {
"table_id": {"type": "string"},
"sheet": {"type": "string"},
"columns": {"type": "array", "items": {"type": "string"}},
"top_values_limit": {"type": "integer", "minimum": 0, "maximum": 10},
},
"required": ["table_id"],
"additionalProperties": False,
},
handler=handler,
read_only=True,
)
__all__ = ["query_tabular_attachment_tool", "run_tabular_sql_tool", "analyze_tabular_attachment_full_scan_tool"]

View file

@ -0,0 +1,150 @@
from __future__ import annotations
import httpx
import re
import unicodedata
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from .geo_http import NOMINATIM_REQUEST_HEADERS, ipapi_approximate_location, nominatim_reverse
_WEATHER_CODES: dict[int, str] = {
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Fog",
48: "Depositing rime fog",
51: "Light drizzle",
53: "Moderate drizzle",
55: "Dense drizzle",
61: "Slight rain",
63: "Moderate rain",
65: "Heavy rain",
71: "Slight snow",
73: "Moderate snow",
75: "Heavy snow",
95: "Thunderstorm",
}
_LOCAL_WEATHER_ALIASES: frozenset[str] = frozenset(
{"here", "local", "locally", "nearby", "current", "current location", "my location", "this location", "local area", "unknown", "anywhere", "本地", "当地", "这里", "附近", "当前位置", "当前", "本地天气"}
)
def _normalize_city_token(s: str) -> str:
t = unicodedata.normalize("NFKC", (s or "").strip()).casefold()
t = re.sub(r"\s+", " ", t)
return t
def _is_local_weather_alias(city: str) -> bool:
return _normalize_city_token(city) in _LOCAL_WEATHER_ALIASES
def _coerce_city(raw: Any) -> str | None:
if raw is None:
return None
if not isinstance(raw, str):
raw = str(raw)
s = raw.strip()
return s if s else None
def weather_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
city = _coerce_city(args.get("city"))
lat = args.get("latitude")
lon = args.get("longitude")
if (lat is None) ^ (lon is None):
return {"ok": False, "error": "Provide both latitude and longitude, or neither (for local-IP weather), or use city alone."}
has_coords = lat is not None and lon is not None
try:
with httpx.Client(timeout=12.0) as client:
location_basis: str
resolved_city: str
lat_f: float
lon_f: float
extra: dict[str, Any] = {}
if has_coords:
lat_f = float(lat)
lon_f = float(lon)
location_basis = "explicit_coordinates"
rev = nominatim_reverse(client, lat_f, lon_f)
dn = (rev.get("display_name") or "").strip() if rev else ""
resolved_city = dn or f"Coordinates ({lat_f}, {lon_f})"
elif city and not _is_local_weather_alias(city):
geo_resp = client.get(
"https://nominatim.openstreetmap.org/search",
params={"q": city, "format": "json", "limit": 1},
headers=NOMINATIM_REQUEST_HEADERS,
)
geo_resp.raise_for_status()
geo_data = geo_resp.json()
if not geo_data:
return {"ok": False, "error": f"City not found: {city}"}
first = geo_data[0]
lat_f = float(first["lat"])
lon_f = float(first["lon"])
resolved_city = first.get("display_name", city)
location_basis = "explicit_place"
else:
ip_loc = ipapi_approximate_location(client)
if not ip_loc:
return {"ok": False, "error": "Could not resolve local weather: failed to detect location from this network. Pass a concrete city/region (e.g. 北京) or both latitude and longitude."}
lat_f = ip_loc["latitude"]
lon_f = ip_loc["longitude"]
resolved_city = ip_loc["display_name"]
location_basis = "local_network_ip"
if ip_loc.get("ip") is not None:
extra["approximate_ip"] = ip_loc["ip"]
weather_url = "https://api.open-meteo.com/v1/forecast"
weather_params = {
"latitude": lat_f,
"longitude": lon_f,
"current": ["temperature_2m", "relative_humidity_2m", "apparent_temperature", "is_day", "weather_code", "wind_speed_10m"],
"timezone": "auto",
}
w_resp = client.get(weather_url, params=weather_params)
w_resp.raise_for_status()
current = w_resp.json().get("current", {})
code = int(current.get("weather_code") or 0)
condition = _WEATHER_CODES.get(code, "Unknown")
out: dict[str, Any] = {
"ok": True,
"city": resolved_city,
"temperature": f"{current.get('temperature_2m')}°C",
"feels_like": f"{current.get('apparent_temperature')}°C",
"condition": condition,
"humidity": f"{current.get('relative_humidity_2m')}%",
"wind_speed": f"{current.get('wind_speed_10m')} km/h",
"is_day": bool(current.get("is_day")),
"latitude": lat_f,
"longitude": lon_f,
"location_basis": location_basis,
}
out.update(extra)
if location_basis == "local_network_ip":
out["disclaimer"] = "Weather is for the approximate location of this deployment's public IP (VPN/proxy/corporate NAT may differ from the end user's actual place)."
return out
except Exception as e:
return {"ok": False, "error": f"Failed to fetch weather: {e}"}
return ToolSpec(
name="get_weather",
description="Get current weather (Open-Meteo, no API key). Default: omit city and coordinates — uses this server's outbound public IP for approximate local weather. Override: pass a concrete placename in `city` or both `latitude` and `longitude`.",
parameters={
"type": "object",
"properties": {
"city": {"type": "string", "description": "Optional place name."},
"latitude": {"type": "number", "description": "Optional. Must pair with longitude."},
"longitude": {"type": "number", "description": "Optional. Must pair with latitude."},
},
"additionalProperties": False,
},
handler=handler,
)
__all__ = ["weather_tool"]

View file

@ -0,0 +1,136 @@
"""基于 DuckDuckGo(ddgs 包)的公网搜索工具(无需 API Key)。"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
_MAX_SNIPPET = 800
_DDGS_TIMEOUT = 15
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _truncate(s: str, limit: int) -> str:
t = (s or "").strip()
if len(t) <= limit:
return t
return t[: limit - 3] + "..."
def _published_display_and_sort_key(raw: Any) -> tuple[str | None, float]:
if raw is None:
return None, float("-inf")
if isinstance(raw, (int, float)):
try:
ts = float(raw)
dt = datetime.fromtimestamp(ts, timezone.utc)
return dt.isoformat(), ts
except (OSError, OverflowError, ValueError):
return str(raw), float("-inf")
s = str(raw).strip()
if not s:
return None, float("-inf")
try:
s2 = s[:-1] + "+00:00" if s.endswith("Z") else s
dt = datetime.fromisoformat(s2)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
iso = dt.astimezone(timezone.utc).isoformat()
return iso, dt.timestamp()
except Exception:
return s, float("-inf")
def web_search_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
q = str(args.get("query") or "").strip()
if not q:
return {"ok": False, "error": "query is required"}
raw_max = args.get("max_results")
try:
max_n = int(raw_max) if raw_max is not None else 8
except (TypeError, ValueError):
max_n = 8
max_n = max(1, min(15, max_n))
stype = str(args.get("search_type") or "web").strip().lower()
if stype not in ("web", "news"):
return {"ok": False, "error": "search_type must be 'web' or 'news'"}
timelimit = args.get("time_range")
if timelimit is not None and timelimit != "":
tl = str(timelimit).strip().lower()
allowed = {"d", "w", "m", "y"}
if tl not in allowed:
return {"ok": False, "error": f"time_range must be one of {sorted(allowed)} or omitted"}
timelimit = tl
else:
timelimit = None
try:
from ddgs import DDGS
except ImportError:
return {"ok": False, "error": "Package `ddgs` is not installed. Run: pip install ddgs"}
retrieved_at = _utc_now_iso()
try:
rows: list[dict[str, Any]] = []
with DDGS(timeout=_DDGS_TIMEOUT) as ddgs:
if stype == "web":
for r in ddgs.text(q, max_results=max_n, timelimit=timelimit):
if not isinstance(r, dict):
continue
title = _truncate(str(r.get("title") or ""), 300)
url = str(r.get("href") or r.get("url") or "").strip()
body = _truncate(str(r.get("body") or ""), _MAX_SNIPPET)
if title or url or body:
rows.append({"title": title, "url": url, "snippet": body, "published_time": None})
sort_mode = "relevance"
note = "Web index does not provide reliable per-result publication times; order follows search relevance. Use search_type=news for time-sorted news."
else:
decorated: list[tuple[float, dict[str, Any]]] = []
for r in ddgs.news(q, max_results=max_n, timelimit=timelimit):
if not isinstance(r, dict):
continue
title = _truncate(str(r.get("title") or ""), 300)
url = str(r.get("url") or r.get("href") or "").strip()
body = _truncate(str(r.get("body") or ""), _MAX_SNIPPET)
pub, sk = _published_display_and_sort_key(r.get("date"))
src = str(r.get("source") or "").strip()
item = {"title": title, "url": url, "snippet": body, "published_time": pub}
if src:
item["source"] = src
if title or url or body:
decorated.append((sk, item))
decorated.sort(key=lambda x: x[0], reverse=True)
rows = [x[1] for x in decorated]
sort_mode = "published_time_desc"
note = "News results sorted by published_time (newest first). Snippets are from third-party indexes; verify critical facts."
if not rows:
return {"ok": True, "query": q, "search_type": stype, "retrieved_at": retrieved_at, "sort": sort_mode, "results": [], "note": "No results (empty or blocked). Try rephrasing the query."}
return {"ok": True, "query": q, "search_type": stype, "retrieved_at": retrieved_at, "sort": sort_mode, "results": rows, "source": "duckduckgo", "note": note}
except Exception as e:
return {"ok": False, "error": f"Web search failed: {e}"}
return ToolSpec(
name="web_search",
description="Search the public web (DuckDuckGo via ddgs, no API key).",
parameters={
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search keywords or question."},
"max_results": {"type": "integer", "description": "Optional. Number of results (1–15). Default 8."},
"search_type": {"type": "string", "enum": ["web", "news"], "description": "Optional. 'web' or 'news'."},
"time_range": {"type": "string", "enum": ["d", "w", "m", "y"], "description": "Optional time limit."},
},
"required": ["query"],
"additionalProperties": False,
},
handler=handler,
)
__all__ = ["web_search_tool"]