refactor(tools): decorator-based tool registry replaces monolithic tools.py

Split 2566-line tools.py into a tools/ package with @tool decorator
registration. Each tool's schema, metadata, and implementation live
together. Briefing eligibility is now a briefing=True flag instead of
a separate frozenset allowlist. Conditional inclusion (CalDAV, SearXNG)
uses requires= metadata. Public API (get_tools_for_user, execute_tool)
unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 10:01:00 -04:00
parent 5275be8588
commit ce2d76447c
15 changed files with 2129 additions and 2624 deletions
+117
View File
@@ -0,0 +1,117 @@
"""Web search, research, and image tools (require SearXNG)."""
from __future__ import annotations
import logging
from fabledassistant.services.tools._registry import tool
logger = logging.getLogger(__name__)
@tool(
name="search_web",
description=(
"Search the web for quick information and answer the user's question from results. "
"Use for factual lookups, current events, version numbers, quick definitions, or any question "
"where a fast web answer is needed without creating a note. "
"Use research_topic instead when the user explicitly wants a comprehensive note or deep research."
),
parameters={
"query": {"type": "string", "description": "The search query"},
},
required=["query"],
requires="searxng",
)
async def search_web_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.research import _search_searxng
query = arguments.get("query", "")
results = await _search_searxng(query)
if not results:
return {"success": False, "error": f"No results found for '{query}'"}
return {
"success": True,
"type": "web_search",
"data": {"query": query, "results": results, "count": len(results)},
}
@tool(
name="research_topic",
description=(
"Research a topic by searching the web and compiling a note with cited sources. "
"Use for 'research X', 'look up X', 'find information about X', "
"'compile notes on X'. Takes 30120 seconds."
),
parameters={
"topic": {"type": "string", "description": "The topic or question to research"},
},
required=["topic"],
requires="searxng",
)
async def research_topic_tool(*, user_id, arguments, **_ctx):
# Research is always intercepted in generation_task.py (round 0) before execute_tool.
# Reaching here indicates a code path regression.
topic = arguments.get("topic", "")
logger.error(
"research_topic reached execute_tool — should have been intercepted upstream "
"in generation_task.py. topic=%r",
topic,
)
return {"success": False, "error": "Research could not be started. Please try again."}
@tool(
name="search_images",
description="Search and display images inline. Use ONLY when user explicitly asks to see or show an image or photo. Not for factual questions.",
parameters={
"query": {"type": "string", "description": "The image search query"},
},
required=["query"],
requires="searxng",
)
async def search_images_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.images import fetch_and_store_image
from fabledassistant.services.research import _search_searxng_images
query = arguments.get("query", "")
if not query:
return {"success": False, "error": "query is required"}
raw_results = await _search_searxng_images(query)
if not raw_results:
return {"success": False, "error": f"No images found for '{query}'"}
images = []
for r in raw_results[:2]:
img_url = r.get("img_src", "")
if not img_url:
continue
record = await fetch_and_store_image(
url=img_url,
title=r.get("title"),
source_domain=r.get("source_domain"),
)
if record:
title = record.title or query
page_url = r.get("page_url", "")
source_domain = r.get("source_domain", "")
images.append({
"embed": f"![{title}](/api/images/{record.id})",
"citation": f"*Source: [{source_domain or 'image'}]({page_url})*",
"page_url": page_url,
"title": title,
})
if not images:
return {"success": False, "error": f"Could not retrieve images for '{query}'"}
return {
"success": True,
"type": "image_search",
"data": {
"query": query,
"images": images,
"instructions": (
"Embed each image in your response by writing the 'embed' field verbatim, "
"then the 'citation' field on the next line. Do not paraphrase or list URLs."
),
},
}