feat(tools): add read_article tool for fetching full article content

Adds read_article tool definition and execute_tool handler. Uses
_fetch_full_article (trafilatura) from rss.py, caps tool output at
40K chars to keep context window manageable. Always registered
(not gated on SearXNG). Tests cover success, failure, truncation,
empty URL, and history builder replay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-05 19:33:11 -04:00
parent f7d54a15c0
commit 278927ec40
2 changed files with 177 additions and 0 deletions
+41
View File
@@ -880,6 +880,28 @@ _IMAGE_TOOLS = [
]
_URL_TOOLS = [
{
"type": "function",
"function": {
"name": "read_article",
"description": (
"Fetch and read the full text of a web page or article from a URL. "
"Use when the user shares a URL and wants you to read it, "
"or to get the full content of a linked page. "
"Do NOT use search_web for URLs — use this tool instead."
),
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "The URL to fetch and read"}
},
"required": ["url"],
},
},
}
]
_RAG_TOOLS = [
{
"type": "function",
@@ -1032,6 +1054,7 @@ _ENTITY_TOOLS = [
async def get_tools_for_user(user_id: int) -> list[dict]:
"""Build the tool list for a user based on their configured integrations."""
tools = list(_CORE_TOOLS)
tools.extend(_URL_TOOLS)
tools.extend(_RAG_TOOLS)
tools.extend(_ENTITY_TOOLS)
if await is_caldav_configured(user_id):
@@ -1768,6 +1791,24 @@ async def execute_tool(
"data": {"project": proj.title, "count": len(summary), "milestones": summary},
}
elif tool_name == "read_article":
from fabledassistant.services.rss import _fetch_full_article
url = arguments.get("url", "").strip()
if not url:
return {"success": False, "error": "No URL provided"}
content = await _fetch_full_article(url)
if not content:
return {"success": False, "error": f"Could not fetch article content from {url}"}
_TOOL_CONTENT_CAP = 40_000
truncated = len(content) > _TOOL_CONTENT_CAP
return {
"success": True,
"type": "article_content",
"url": url,
"content": content[:_TOOL_CONTENT_CAP],
"truncated": truncated,
}
elif tool_name == "search_web":
from fabledassistant.services.research import _search_searxng
query = arguments.get("query", "")
+136
View File
@@ -0,0 +1,136 @@
import json
import pytest
from unittest.mock import patch, AsyncMock
# ---------------------------------------------------------------------------
# read_article tool tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_read_article_success():
"""read_article returns success with content when _fetch_full_article succeeds."""
from fabledassistant.services.tools import execute_tool
with patch(
"fabledassistant.services.rss._fetch_full_article",
new_callable=AsyncMock,
return_value="Article body text here.",
):
result = await execute_tool(user_id=1, tool_name="read_article", arguments={"url": "https://example.com/article"})
assert result["success"] is True
assert result["type"] == "article_content"
assert result["url"] == "https://example.com/article"
assert result["content"] == "Article body text here."
assert result["truncated"] is False
@pytest.mark.asyncio
async def test_read_article_fetch_failure():
"""read_article returns success=False when _fetch_full_article returns None."""
from fabledassistant.services.tools import execute_tool
with patch(
"fabledassistant.services.rss._fetch_full_article",
new_callable=AsyncMock,
return_value=None,
):
result = await execute_tool(user_id=1, tool_name="read_article", arguments={"url": "https://example.com/broken"})
assert result["success"] is False
assert "Could not fetch" in result["error"]
@pytest.mark.asyncio
async def test_read_article_truncates_at_40k():
"""read_article truncates content at 40,000 chars and sets truncated=True."""
from fabledassistant.services.tools import execute_tool
long_content = "x" * 50_000
with patch(
"fabledassistant.services.rss._fetch_full_article",
new_callable=AsyncMock,
return_value=long_content,
):
result = await execute_tool(user_id=1, tool_name="read_article", arguments={"url": "https://example.com/long"})
assert result["success"] is True
assert len(result["content"]) == 40_000
assert result["truncated"] is True
@pytest.mark.asyncio
async def test_read_article_empty_url():
"""read_article returns success=False when URL is empty."""
from fabledassistant.services.tools import execute_tool
result = await execute_tool(user_id=1, tool_name="read_article", arguments={"url": ""})
assert result["success"] is False
assert "No URL provided" in result["error"]
# ---------------------------------------------------------------------------
# History builder tests
# ---------------------------------------------------------------------------
def _build_history(messages: list[dict]) -> list[dict]:
"""Replicate the fixed history builder from routes/chat.py."""
history = []
for msg in messages:
if msg["role"] == "system":
continue
msg_dict = {"role": msg["role"], "content": msg.get("content") or ""}
tool_calls = msg.get("tool_calls")
if tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in tool_calls
]
history.append(msg_dict)
for tc in tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
return history
def test_history_builder_replays_tool_calls():
"""History builder with tool_calls produces assistant entry + tool result entry."""
messages = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"function": "read_article",
"arguments": {"url": "https://example.com"},
"result": {"success": True, "content": "Article text"},
}
],
},
{"role": "user", "content": "Summarize it", "tool_calls": None},
]
history = _build_history(messages)
assert len(history) == 3
assert history[0]["role"] == "assistant"
assert history[0]["tool_calls"][0]["function"]["name"] == "read_article"
assert history[1]["role"] == "tool"
assert json.loads(history[1]["content"])["success"] is True
assert history[2]["role"] == "user"
assert history[2]["content"] == "Summarize it"
def test_history_builder_no_tool_calls_unchanged():
"""History builder with tool_calls=None produces same output as before."""
messages = [
{"role": "user", "content": "Hello", "tool_calls": None},
{"role": "assistant", "content": "Hi there!", "tool_calls": None},
]
history = _build_history(messages)
assert len(history) == 2
assert history[0] == {"role": "user", "content": "Hello"}
assert history[1] == {"role": "assistant", "content": "Hi there!"}