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:
@@ -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", "")
|
||||
|
||||
@@ -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!"}
|
||||
Reference in New Issue
Block a user