diff --git a/src/fabledassistant/services/tools.py b/src/fabledassistant/services/tools.py index e90e8a4..dd58ba2 100644 --- a/src/fabledassistant/services/tools.py +++ b/src/fabledassistant/services/tools.py @@ -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", "") diff --git a/tests/test_article_reading.py b/tests/test_article_reading.py new file mode 100644 index 0000000..c0cb6e3 --- /dev/null +++ b/tests/test_article_reading.py @@ -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!"}