refactor(subagents): split scraping into web_crawler + youtube builtins

This commit is contained in:
CREDO23 2026-07-03 11:20:09 +02:00
parent 3312a442f3
commit 5da624399d
25 changed files with 239 additions and 808 deletions

View file

@ -26,7 +26,8 @@ CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS: dict[str, str] = {
SUBAGENT_TO_REQUIRED_CONNECTOR_MAP: dict[str, frozenset[str]] = {
"deliverables": frozenset(),
"knowledge_base": frozenset(),
"scraping": frozenset(),
"web_crawler": frozenset(),
"youtube": frozenset(),
"airtable": frozenset({"AIRTABLE_CONNECTOR"}),
"calendar": frozenset({"GOOGLE_CALENDAR_CONNECTOR"}),
"clickup": frozenset({"CLICKUP_CONNECTOR"}),

View file

@ -1,2 +0,0 @@
Scraping specialist: fetches live public web pages, searches the web to find pages, pulls structured data from YouTube (videos, channels, playlists, search, and comments), and compares fresh data against earlier findings in this chat.
Use whenever the task needs current data pulled from the open web or YouTube rather than the workspace's own documents or connectors. Triggers include "scrape", "crawl", "fetch this URL/page", "search the web", "look up / find online", "check the price/stock/listing", "monitor this page", "what changed since last time" — plus YouTube-specific asks like "get this YouTube video/channel/playlist", "find videos about X on YouTube", "get the comments on this video", "what are people saying about this video" — and recurring versions ("check daily", "tell me weekly what changed"), which it can also schedule as an ongoing watch.

View file

@ -1,65 +0,0 @@
You are the SurfSense scraping sub-agent.
You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis.
<goal>
Answer the delegated question from live evidence gathered with your data verbs, including "what changed" comparisons against evidence already in this conversation.
</goal>
<available_tools>
- `web_discover`
- `web_scrape`
- `youtube_scrape`
- `youtube_comments`
- `start_watch`
- `stop_watch`
- `refresh_watch`
</available_tools>
<playbook>
- Named URLs: `web_scrape` them directly. Otherwise `web_discover` first, then `web_scrape` the most relevant hits.
- Read several pages in one batched `web_scrape` call rather than many single-URL calls.
- YouTube: use `youtube_scrape` for videos/channels/playlists (pass `urls`) or to find videos (pass `search_queries`); use `youtube_comments` to pull a video's comments. Prefer these over `web_scrape` for youtube.com links.
- "What changed" / monitoring: scrape the current values, compare against the prior values in this conversation's earlier tool results, and report concrete deltas (added, removed, old -> new).
- Recurring intent ("check daily", "tell me weekly what changed"): answer now, then `start_watch` with a self-contained question, a cron cadence, and an IANA timezone. Use `stop_watch` / `refresh_watch` to end or immediately re-run an existing watch.
</playbook>
<tool_policy>
- Use only tools in `<available_tools>`.
- A `web_scrape` row whose `status` is not `success` returned no content — report it unavailable, never invent it.
- Report only deltas you can point to in the evidence. Never fabricate facts, URLs, prices, or quotes.
</tool_policy>
<out_of_scope>
- Do not generate deliverables or perform connector mutations; return findings for the supervisor to act on.
</out_of_scope>
<safety>
- Report uncertainty explicitly when evidence is incomplete or conflicting.
- Never present unverified claims as facts.
</safety>
<failure_policy>
- Underspecified request — including a recurring request whose cadence or timezone is neither given nor implied — return `status=blocked` with the missing fields.
- Tool failure: return `status=error` with a concise recovery `next_step`.
- No useful evidence: return `status=blocked` with a narrower query or the URLs you still need.
</failure_policy>
<output_contract>
Return **only** one JSON object (no markdown/prose):
{
"status": "success" | "partial" | "blocked" | "error",
"action_summary": string,
"evidence": {
"findings": string[],
"sources": string[],
"confidence": "high" | "medium" | "low"
},
"next_step": string | null,
"missing_fields": string[] | null,
"assumptions": string[] | null
}
<include snippet="output_contract_base"/>
Route-specific rules:
- `evidence.findings`: max 10 entries, each a single sentence stating one distinct fact or delta. Do not paste raw scraped pages.
- `evidence.sources`: max 10 URLs, one per finding when applicable. List each URL once.
</output_contract>

View file

@ -1 +0,0 @@
"""Intelligence-agent tools: the web.* capability verbs, generated from the registry."""

View file

@ -1,49 +0,0 @@
"""``scraping`` sub-agent tools: scraper capability verbs + chat-watch controls."""
from __future__ import annotations
from typing import Any
from langchain_core.tools import BaseTool
from app.agents.chat.multi_agent_chat.shared.permissions import Ruleset
from app.capabilities.core.access.agent import build_capability_tools
from app.capabilities.web.crawl.definition import WEB_CRAWL
from app.capabilities.youtube.comments.definition import YOUTUBE_COMMENTS
from app.capabilities.youtube.scrape.definition import YOUTUBE_SCRAPE
from .refresh_watch import create_refresh_watch_tool
from .start_watch import create_start_watch_tool
from .stop_watch import create_stop_watch_tool
NAME = "scraping"
RULESET = Ruleset(origin=NAME, rules=[])
_CI_VERBS = [WEB_CRAWL, YOUTUBE_SCRAPE, YOUTUBE_COMMENTS]
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> list[BaseTool]:
d = {**(dependencies or {}), **kwargs}
tools: list[BaseTool] = build_capability_tools(
workspace_id=d.get("workspace_id"),
capabilities=_CI_VERBS,
)
# Watch tools bind a recurring automation to the current chat, so they are
# only offered when we have a chat to bind to and auth to manage it.
thread_id = d.get("thread_id")
auth_context = d.get("auth_context")
if thread_id is not None and auth_context is not None:
binding = {
"workspace_id": d.get("workspace_id"),
"thread_id": thread_id,
"auth_context": auth_context,
}
tools.append(create_start_watch_tool(**binding))
tools.append(create_stop_watch_tool(**binding))
tools.append(create_refresh_watch_tool(**binding))
return tools

View file

@ -1,82 +0,0 @@
"""``refresh_watch`` — run the current chat's watch now (manual refresh).
Enqueues an immediate run of every watch bound to this chat, instead of waiting
for the next scheduled fire. Operates on the current thread only.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import HTTPException
from langchain_core.tools import tool
from app.auth.context import AuthContext
from app.automations.services.automation import AutomationService
from app.automations.services.chat_watch import find_watches_for_thread, run_watch_now
from app.db import async_session_maker
logger = logging.getLogger(__name__)
def create_refresh_watch_tool(
*,
workspace_id: int | None,
thread_id: int | str | None,
auth_context: AuthContext | None,
):
"""Build the ``refresh_watch`` tool bound to the current chat."""
@tool
async def refresh_watch() -> dict[str, Any]:
"""Run THIS chat's watch now (a manual refresh).
Use when the user says "check now", "refresh", or "run it again"
without waiting for the schedule. No arguments it acts on the
current chat. The refreshed answer arrives as a new turn shortly after.
Returns:
``{"status": "refreshing", "refreshed_ids": [int], "count": int}``
when runs were enqueued, ``{"status": "not_watching", ...}`` when
there is no watch, or ``{"status": "error", "message": str}``.
"""
if thread_id is None or auth_context is None:
return {
"status": "error",
"message": "Watches can only be managed from inside a chat.",
}
try:
async with async_session_maker() as session:
service = AutomationService(session=session, auth=auth_context)
watches = await find_watches_for_thread(
service,
workspace_id=int(workspace_id) if workspace_id is not None else 0,
thread_id=int(thread_id),
)
if not watches:
return {
"status": "not_watching",
"message": "This chat has no active watch to refresh.",
}
refreshed_ids = []
for watch in watches:
await run_watch_now(service, automation_id=watch.id)
refreshed_ids.append(watch.id)
except HTTPException as exc:
return {"status": "error", "message": str(exc.detail)}
except Exception as exc:
logger.exception("refresh_watch failed")
return {"status": "error", "message": f"could not refresh watch: {exc}"}
return {
"status": "refreshing",
"refreshed_ids": refreshed_ids,
"count": len(refreshed_ids),
}
return refresh_watch
__all__ = ["create_refresh_watch_tool"]

View file

@ -1,97 +0,0 @@
"""``start_watch`` tool — bind a recurring watch to the current chat.
Creates a ``schedule`` + ``chat_message`` automation that re-posts the question
into this chat on a cadence, so the agent re-answers against the chat's memory.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import HTTPException
from langchain_core.tools import tool
from app.auth.context import AuthContext
from app.automations.services.automation import AutomationService
from app.automations.services.chat_watch import create_watch
from app.db import async_session_maker
logger = logging.getLogger(__name__)
def create_start_watch_tool(
*,
workspace_id: int | None,
thread_id: int | str | None,
auth_context: AuthContext | None,
):
"""Build the ``start_watch`` tool bound to the current chat.
``thread_id`` and ``auth_context`` are injected from the chat session so the
model never guesses them; a fresh session is opened per call.
"""
@tool
async def start_watch(message: str, cron: str, timezone: str) -> dict[str, Any]:
"""Keep watching: re-run this question on a schedule in THIS chat.
Use when the user wants an answer refreshed on a cadence without
re-asking (e.g. "check this page every weekday and tell me what
changed"). The watch re-posts ``message`` into this chat on the
schedule; the user sees new answers here and can say "stop watching"
to cancel.
Args:
message: The question to re-ask each run, self-contained (include
the URL / target). Written as if the user asked it again.
cron: Five-field cron expression for the cadence
(e.g. "0 9 * * 1-5" = weekdays at 09:00).
timezone: IANA timezone for the schedule (e.g. "UTC",
"Africa/Kigali"). Ask the user if unclear.
Returns:
``{"status": "watching", "automation_id": int, "name": str,
"cron": str, "timezone": str}`` on success.
``{"status": "error", "message": str}`` when the watch could not
be created (e.g. workspace model not billable for automations).
"""
if thread_id is None or auth_context is None:
return {
"status": "error",
"message": "Watches can only be started from inside a chat.",
}
try:
async with async_session_maker() as session:
service = AutomationService(session=session, auth=auth_context)
created = await create_watch(
service,
workspace_id=int(workspace_id) if workspace_id is not None else 0,
thread_id=int(thread_id),
message=message,
cron=cron,
timezone=timezone,
)
except HTTPException as exc:
return {"status": "error", "message": str(exc.detail)}
except Exception as exc:
from langgraph.errors import GraphInterrupt
if isinstance(exc, GraphInterrupt):
raise
logger.exception("start_watch failed")
return {"status": "error", "message": f"could not start watch: {exc}"}
return {
"status": "watching",
"automation_id": created.id,
"name": created.name,
"cron": cron,
"timezone": timezone,
}
return start_watch
__all__ = ["create_start_watch_tool"]

View file

@ -1,85 +0,0 @@
"""``stop_watch`` — stop watching in the current chat.
Deletes every watch automation bound to this chat, so it reverts to a normal
chat. Operates on the current thread only; the model passes no arguments.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import HTTPException
from langchain_core.tools import tool
from app.auth.context import AuthContext
from app.automations.services.automation import AutomationService
from app.automations.services.chat_watch import (
find_watches_for_thread,
stop_watch as stop_watch_service,
)
from app.db import async_session_maker
logger = logging.getLogger(__name__)
def create_stop_watch_tool(
*,
workspace_id: int | None,
thread_id: int | str | None,
auth_context: AuthContext | None,
):
"""Build the ``stop_watch`` tool bound to the current chat."""
@tool
async def stop_watch() -> dict[str, Any]:
"""Stop watching in THIS chat (cancel the recurring re-asks).
Use when the user says something like "stop watching", "cancel the
watch", or "you can stop checking now". The chat reverts to a normal
chat. No arguments it acts on the current chat.
Returns:
``{"status": "stopped", "stopped_ids": [int], "count": int}`` when
watches were removed, ``{"status": "not_watching", ...}`` when there
was nothing to stop, or ``{"status": "error", "message": str}``.
"""
if thread_id is None or auth_context is None:
return {
"status": "error",
"message": "Watches can only be managed from inside a chat.",
}
try:
async with async_session_maker() as session:
service = AutomationService(session=session, auth=auth_context)
watches = await find_watches_for_thread(
service,
workspace_id=int(workspace_id) if workspace_id is not None else 0,
thread_id=int(thread_id),
)
if not watches:
return {
"status": "not_watching",
"message": "This chat has no active watch.",
}
stopped_ids = []
for watch in watches:
await stop_watch_service(service, automation_id=watch.id)
stopped_ids.append(watch.id)
except HTTPException as exc:
return {"status": "error", "message": str(exc.detail)}
except Exception as exc:
logger.exception("stop_watch failed")
return {"status": "error", "message": f"could not stop watch: {exc}"}
return {
"status": "stopped",
"stopped_ids": stopped_ids,
"count": len(stopped_ids),
}
return stop_watch
__all__ = ["create_stop_watch_tool"]

View file

@ -0,0 +1 @@
"""``web_crawler`` builtin subagent: crawl single URLs or spider whole sites."""

View file

@ -1,4 +1,4 @@
"""``scraping`` route: ``SurfSenseSubagentSpec`` builder for deepagents."""
"""``web_crawler`` route: ``SurfSenseSubagentSpec`` builder for deepagents."""
from __future__ import annotations
@ -28,7 +28,7 @@ def build_subagent(
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
description = (
read_md_file(__package__, "description").strip()
or "Scrapes live public web pages and search results for this workspace."
or "Crawls live public web pages and whole sites for this workspace."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(

View file

@ -0,0 +1,2 @@
Web crawling specialist: fetches live public web pages by URL and can spider a whole website (follow its links to a given depth), returning clean markdown, page metadata, and crawl provenance. Also compares freshly crawled data against earlier findings in this chat.
Use whenever the task needs current content pulled from the open web rather than the workspace's own documents or connectors. Triggers include "scrape", "crawl", "fetch this URL/page", "read this website", "crawl this site", "get the pages under X", "check the price/stock/listing", "monitor this page", and "what changed since last time". Not for YouTube links (use the youtube specialist) or for searching the web to discover unknown URLs.

View file

@ -0,0 +1,60 @@
You are the SurfSense web crawling sub-agent.
You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis.
<goal>
Answer the delegated question from live web evidence gathered with `web_crawl`, including "what changed" comparisons against evidence already in this conversation.
</goal>
<available_tools>
- `web_crawl`
</available_tools>
<playbook>
- Single page(s): call `web_crawl` with the URL(s) in `startUrls` and `maxCrawlDepth=0`.
- Whole site / "pages under X": set `maxCrawlDepth` to 1+ to follow links, and cap the run with `maxCrawlPages`. The crawl stays on the start URL's site.
- Batch known URLs into one `web_crawl` call (pass them all in `startUrls`) rather than many single-URL calls.
- Keep depth and page caps as small as the task allows — each fetched page is billable.
- "What changed" / monitoring: crawl the current values, compare against the prior values in this conversation's earlier tool results, and report concrete deltas (added, removed, old -> new).
</playbook>
<tool_policy>
- Use only tools in `<available_tools>`.
- A `web_crawl` item whose `status` is not `success` returned no content — report it unavailable, never invent it.
- Report only deltas you can point to in the evidence. Never fabricate facts, URLs, prices, or quotes.
</tool_policy>
<out_of_scope>
- Do not generate deliverables or perform connector mutations; return findings for the supervisor to act on.
- YouTube URLs belong to the youtube specialist, not here.
</out_of_scope>
<safety>
- Report uncertainty explicitly when evidence is incomplete or conflicting.
- Never present unverified claims as facts.
</safety>
<failure_policy>
- Underspecified request — no usable URL to start from — return `status=blocked` with the missing fields.
- Tool failure: return `status=error` with a concise recovery `next_step`.
- No useful evidence: return `status=blocked` with the URLs you still need or a narrower scope.
</failure_policy>
<output_contract>
Return **only** one JSON object (no markdown/prose):
{
"status": "success" | "partial" | "blocked" | "error",
"action_summary": string,
"evidence": {
"findings": string[],
"sources": string[],
"confidence": "high" | "medium" | "low"
},
"next_step": string | null,
"missing_fields": string[] | null,
"assumptions": string[] | null
}
<include snippet="output_contract_base"/>
Route-specific rules:
- `evidence.findings`: max 10 entries, each a single sentence stating one distinct fact or delta. Do not paste raw crawled pages.
- `evidence.sources`: max 10 URLs, one per finding when applicable. List each URL once.
</output_contract>

View file

@ -0,0 +1,27 @@
"""``web_crawler`` sub-agent tools: the unified web.crawl capability verb."""
from __future__ import annotations
from typing import Any
from langchain_core.tools import BaseTool
from app.agents.chat.multi_agent_chat.shared.permissions import Ruleset
from app.capabilities.core.access.agent import build_capability_tools
from app.capabilities.web.crawl.definition import WEB_CRAWL
NAME = "web_crawler"
RULESET = Ruleset(origin=NAME, rules=[])
_CI_VERBS = [WEB_CRAWL]
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> list[BaseTool]:
d = {**(dependencies or {}), **kwargs}
return build_capability_tools(
workspace_id=d.get("workspace_id"),
capabilities=_CI_VERBS,
)

View file

@ -0,0 +1 @@
"""``youtube`` builtin subagent: structured YouTube video/channel/comment data."""

View file

@ -0,0 +1,43 @@
"""``youtube`` route: ``SurfSenseSubagentSpec`` builder for deepagents."""
from __future__ import annotations
from typing import Any
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.chat.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.chat.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.chat.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from .tools.index import NAME, RULESET, load_tools
def build_subagent(
*,
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
description = (
read_md_file(__package__, "description").strip()
or "Pulls structured data from YouTube videos, channels, playlists, and comments."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -0,0 +1,2 @@
YouTube specialist: pulls structured data from YouTube — videos, channels, playlists, shorts, and hashtags (title, views, likes, publish date, channel info, description, optional subtitles), finds videos by search query, and fetches a video's comments and replies. Also compares fresh YouTube data against earlier findings in this chat.
Use whenever the task involves YouTube content or a youtube.com/youtu.be link. Triggers include "get this YouTube video/channel/playlist", "find videos about X on YouTube", "how many views/likes", "get the transcript/subtitles", "get the comments on this video", "what are people saying about this video", and recurring "what changed" checks on YouTube data. Not for general web pages (use the web crawling specialist for non-YouTube URLs).

View file

@ -0,0 +1,61 @@
You are the SurfSense YouTube sub-agent.
You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis.
<goal>
Answer the delegated question from live YouTube data gathered with your verbs, including "what changed" comparisons against evidence already in this conversation.
</goal>
<available_tools>
- `youtube_scrape`
- `youtube_comments`
</available_tools>
<playbook>
- Known video/channel/playlist/shorts/hashtag links: call `youtube_scrape` with the links in `urls`.
- Finding videos on a topic: call `youtube_scrape` with `search_queries`.
- Comments / sentiment on specific videos: call `youtube_comments` with the video `urls`.
- Batch multiple URLs (or queries) into one call rather than many single-item calls.
- "What changed" / monitoring: pull the current values, compare against the prior values in this conversation's earlier tool results, and report concrete deltas (added, removed, old -> new).
</playbook>
<tool_policy>
- Use only tools in `<available_tools>`.
- An item whose `status` is not `success` returned no data — report it unavailable, never invent it.
- Report only deltas you can point to in the evidence. Never fabricate facts, counts, quotes, or URLs.
</tool_policy>
<out_of_scope>
- Do not generate deliverables or perform connector mutations; return findings for the supervisor to act on.
- Non-YouTube web pages belong to the web crawling specialist, not here.
</out_of_scope>
<safety>
- Report uncertainty explicitly when evidence is incomplete or conflicting.
- Never present unverified claims as facts.
</safety>
<failure_policy>
- Underspecified request — no usable URL or search query — return `status=blocked` with the missing fields.
- Tool failure: return `status=error` with a concise recovery `next_step`.
- No useful evidence: return `status=blocked` with a narrower query or the URLs you still need.
</failure_policy>
<output_contract>
Return **only** one JSON object (no markdown/prose):
{
"status": "success" | "partial" | "blocked" | "error",
"action_summary": string,
"evidence": {
"findings": string[],
"sources": string[],
"confidence": "high" | "medium" | "low"
},
"next_step": string | null,
"missing_fields": string[] | null,
"assumptions": string[] | null
}
<include snippet="output_contract_base"/>
Route-specific rules:
- `evidence.findings`: max 10 entries, each a single sentence stating one distinct fact or delta. Do not paste raw payloads.
- `evidence.sources`: max 10 URLs, one per finding when applicable. List each URL once.
</output_contract>

View file

@ -0,0 +1,28 @@
"""``youtube`` sub-agent tools: the YouTube scrape + comments capability verbs."""
from __future__ import annotations
from typing import Any
from langchain_core.tools import BaseTool
from app.agents.chat.multi_agent_chat.shared.permissions import Ruleset
from app.capabilities.core.access.agent import build_capability_tools
from app.capabilities.youtube.comments.definition import YOUTUBE_COMMENTS
from app.capabilities.youtube.scrape.definition import YOUTUBE_SCRAPE
NAME = "youtube"
RULESET = Ruleset(origin=NAME, rules=[])
_CI_VERBS = [YOUTUBE_SCRAPE, YOUTUBE_COMMENTS]
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> list[BaseTool]:
d = {**(dependencies or {}), **kwargs}
return build_capability_tools(
workspace_id=d.get("workspace_id"),
capabilities=_CI_VERBS,
)

View file

@ -24,8 +24,11 @@ from app.agents.chat.multi_agent_chat.subagents.builtins.memory.agent import (
from app.agents.chat.multi_agent_chat.subagents.builtins.research.agent import (
build_subagent as build_research_subagent,
)
from app.agents.chat.multi_agent_chat.subagents.builtins.scraping.agent import (
build_subagent as build_scraping_subagent,
from app.agents.chat.multi_agent_chat.subagents.builtins.web_crawler.agent import (
build_subagent as build_web_crawler_subagent,
)
from app.agents.chat.multi_agent_chat.subagents.builtins.youtube.agent import (
build_subagent as build_youtube_subagent,
)
from app.agents.chat.multi_agent_chat.subagents.connectors.airtable.agent import (
build_subagent as build_airtable_subagent,
@ -110,9 +113,10 @@ SUBAGENT_BUILDERS_BY_NAME: dict[str, SubagentBuilder] = {
"notion": build_notion_subagent,
"onedrive": build_onedrive_subagent,
"research": build_research_subagent,
"scraping": build_scraping_subagent,
"slack": build_slack_subagent,
"teams": build_teams_subagent,
"web_crawler": build_web_crawler_subagent,
"youtube": build_youtube_subagent,
}

View file

@ -1,182 +0,0 @@
"""Integration tests for the watch chat tools against real service + Postgres.
The tools are thin adapters the intelligence agent calls; here they run their
real path open a session, build ``AutomationService``, and create / find /
delete / enqueue against real persistence (RBAC + model gate included), with
only the Celery enqueue spied. This is what proves ``start_watch`` actually
binds a watch, ``stop_watch`` removes it, and ``refresh_watch`` enqueues a run.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.chat.multi_agent_chat.subagents.builtins.scraping.tools.refresh_watch import (
create_refresh_watch_tool,
)
from app.agents.chat.multi_agent_chat.subagents.builtins.scraping.tools.start_watch import (
create_start_watch_tool,
)
from app.agents.chat.multi_agent_chat.subagents.builtins.scraping.tools.stop_watch import (
create_stop_watch_tool,
)
from app.auth.context import AuthContext
from app.automations.services.automation import AutomationService
from app.automations.services.chat_watch import find_watches_for_thread
from app.db import ChatVisibility, NewChatThread, User, Workspace
pytestmark = pytest.mark.integration
_CRON = "0 9 * * 1-5"
_TZ = "UTC"
def _billable(workspace: Workspace) -> None:
workspace.chat_model_id = 1
workspace.image_gen_model_id = 1
workspace.vision_model_id = 1
@pytest_asyncio.fixture
async def thread(
db_session: AsyncSession, db_user: User, db_workspace: Workspace
) -> NewChatThread:
row = NewChatThread(
title="Watched chat",
workspace_id=db_workspace.id,
created_by_id=db_user.id,
visibility=ChatVisibility.PRIVATE,
)
db_session.add(row)
await db_session.flush()
return row
async def test_start_watch_tool_binds_a_watch_to_the_chat(
tools_use_test_session,
db_session: AsyncSession,
db_user: User,
db_workspace: Workspace,
thread: NewChatThread,
) -> None:
_billable(db_workspace)
await db_session.flush()
tool = create_start_watch_tool(
workspace_id=db_workspace.id,
thread_id=thread.id,
auth_context=AuthContext.session(db_user),
)
out = await tool.ainvoke(
{"message": "what changed?", "cron": _CRON, "timezone": _TZ}
)
assert out["status"] == "watching"
assert isinstance(out["automation_id"], int)
service = AutomationService(session=db_session, auth=AuthContext.session(db_user))
found = await find_watches_for_thread(
service, workspace_id=db_workspace.id, thread_id=thread.id
)
assert [a.id for a in found] == [out["automation_id"]]
async def test_start_watch_tool_reports_error_when_models_not_billable(
tools_use_test_session,
db_user: User,
db_workspace: Workspace,
thread: NewChatThread,
) -> None:
# Default workspace models are Auto/None -> the automation gate rejects it.
tool = create_start_watch_tool(
workspace_id=db_workspace.id,
thread_id=thread.id,
auth_context=AuthContext.session(db_user),
)
out = await tool.ainvoke(
{"message": "track it", "cron": _CRON, "timezone": _TZ}
)
assert out["status"] == "error"
async def test_stop_watch_tool_removes_the_chats_watch(
tools_use_test_session,
db_session: AsyncSession,
db_user: User,
db_workspace: Workspace,
thread: NewChatThread,
) -> None:
_billable(db_workspace)
await db_session.flush()
auth = AuthContext.session(db_user)
start = create_start_watch_tool(
workspace_id=db_workspace.id, thread_id=thread.id, auth_context=auth
)
await start.ainvoke({"message": "m", "cron": _CRON, "timezone": _TZ})
stop = create_stop_watch_tool(
workspace_id=db_workspace.id, thread_id=thread.id, auth_context=auth
)
out = await stop.ainvoke({})
assert out["status"] == "stopped"
assert out["count"] == 1
service = AutomationService(session=db_session, auth=auth)
found = await find_watches_for_thread(
service, workspace_id=db_workspace.id, thread_id=thread.id
)
assert found == []
async def test_stop_watch_tool_reports_not_watching_when_none(
tools_use_test_session,
db_workspace: Workspace,
db_user: User,
thread: NewChatThread,
) -> None:
stop = create_stop_watch_tool(
workspace_id=db_workspace.id,
thread_id=thread.id,
auth_context=AuthContext.session(db_user),
)
out = await stop.ainvoke({})
assert out["status"] == "not_watching"
async def test_refresh_watch_tool_enqueues_a_run(
tools_use_test_session,
enqueue_spy: list[dict],
db_session: AsyncSession,
db_user: User,
db_workspace: Workspace,
thread: NewChatThread,
) -> None:
_billable(db_workspace)
await db_session.flush()
auth = AuthContext.session(db_user)
start = create_start_watch_tool(
workspace_id=db_workspace.id, thread_id=thread.id, auth_context=auth
)
started = await start.ainvoke({"message": "m", "cron": _CRON, "timezone": _TZ})
refresh = create_refresh_watch_tool(
workspace_id=db_workspace.id, thread_id=thread.id, auth_context=auth
)
out = await refresh.ainvoke({})
assert out["status"] == "refreshing"
assert out["refreshed_ids"] == [started["automation_id"]]
assert len(enqueue_spy) == 1
async def test_tools_error_without_thread_or_auth() -> None:
tool = create_start_watch_tool(
workspace_id=1, thread_id=None, auth_context=None
)
out = await tool.ainvoke({"message": "m", "cron": _CRON, "timezone": _TZ})
assert out["status"] == "error"

View file

@ -1,118 +0,0 @@
"""Unit tests for the ``start_watch`` tool on the scraping sub-agent.
``start_watch`` binds a recurring watch to the *current* chat: it distils the
question + cadence the agent extracted and creates a ``schedule`` +
``chat_message`` automation via the watch service. These tests fake the watch
service / session so no DB is touched; they pin that the tool forwards the
current chat id + system auth and reports a clear outcome.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
pytestmark = pytest.mark.unit
class _FakeSessionCM:
async def __aenter__(self) -> Any:
return MagicMock(name="session")
async def __aexit__(self, *_exc: Any) -> bool:
return False
def _patch_deps(monkeypatch: pytest.MonkeyPatch, *, created: Any) -> AsyncMock:
from app.agents.chat.multi_agent_chat.subagents.builtins.scraping.tools import (
start_watch as mod,
)
fake_create_watch = AsyncMock(return_value=created)
monkeypatch.setattr(mod, "create_watch", fake_create_watch)
monkeypatch.setattr(mod, "AutomationService", MagicMock(return_value="svc"))
monkeypatch.setattr(mod, "async_session_maker", lambda: _FakeSessionCM())
return fake_create_watch
@pytest.mark.asyncio
async def test_start_watch_binds_watch_to_current_chat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.agents.chat.multi_agent_chat.subagents.builtins.scraping.tools import (
start_watch as mod,
)
created = MagicMock(id=123)
created.name = "Watch: prices"
fake_create_watch = _patch_deps(monkeypatch, created=created)
auth = MagicMock()
tool = mod.create_start_watch_tool(workspace_id=3, thread_id=55, auth_context=auth)
out = await tool.ainvoke(
{"message": "what changed on prices?", "cron": "0 9 * * 1-5", "timezone": "UTC"}
)
assert out["status"] == "watching"
assert out["automation_id"] == 123
fake_create_watch.assert_awaited_once()
kwargs = fake_create_watch.await_args.kwargs
assert kwargs["workspace_id"] == 3
assert kwargs["thread_id"] == 55
assert kwargs["message"] == "what changed on prices?"
assert kwargs["cron"] == "0 9 * * 1-5"
assert kwargs["timezone"] == "UTC"
# The service is constructed with the passed-through auth context.
assert mod.AutomationService.call_args.kwargs["auth"] is auth
@pytest.mark.asyncio
async def test_start_watch_errors_without_thread_or_auth(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.agents.chat.multi_agent_chat.subagents.builtins.scraping.tools import (
start_watch as mod,
)
fake_create_watch = _patch_deps(monkeypatch, created=MagicMock(id=1))
no_thread = mod.create_start_watch_tool(
workspace_id=3, thread_id=None, auth_context=MagicMock()
)
out_no_thread = await no_thread.ainvoke(
{"message": "x", "cron": "0 9 * * *", "timezone": "UTC"}
)
assert out_no_thread["status"] == "error"
no_auth = mod.create_start_watch_tool(
workspace_id=3, thread_id=55, auth_context=None
)
out_no_auth = await no_auth.ainvoke(
{"message": "x", "cron": "0 9 * * *", "timezone": "UTC"}
)
assert out_no_auth["status"] == "error"
fake_create_watch.assert_not_awaited()
def test_load_tools_includes_start_watch_only_when_bindable() -> None:
from app.agents.chat.multi_agent_chat.subagents.builtins.scraping.tools.index import (
load_tools,
)
bindable = load_tools(
dependencies={
"workspace_id": 3,
"thread_id": 55,
"auth_context": MagicMock(),
}
)
assert "start_watch" in {t.name for t in bindable}
not_bindable = load_tools(dependencies={"workspace_id": 3})
assert "start_watch" not in {t.name for t in not_bindable}

View file

@ -1,119 +0,0 @@
"""Unit tests for the ``stop_watch`` and ``refresh_watch`` chat tools.
Both act on the *current* chat: they look up the watches bound to this thread
and stop them / run them now. The watch service + session are faked so no DB is
touched.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
pytestmark = pytest.mark.unit
class _FakeSessionCM:
async def __aenter__(self) -> Any:
return MagicMock(name="session")
async def __aexit__(self, *_exc: Any) -> bool:
return False
def _watch(automation_id: int) -> Any:
a = MagicMock()
a.id = automation_id
return a
@pytest.mark.asyncio
async def test_stop_watch_stops_every_watch_on_the_chat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.agents.chat.multi_agent_chat.subagents.builtins.scraping.tools import (
stop_watch as mod,
)
monkeypatch.setattr(mod, "AutomationService", MagicMock(return_value="svc"))
monkeypatch.setattr(mod, "async_session_maker", lambda: _FakeSessionCM())
monkeypatch.setattr(
mod, "find_watches_for_thread", AsyncMock(return_value=[_watch(1), _watch(2)])
)
stop_service = AsyncMock()
monkeypatch.setattr(mod, "stop_watch_service", stop_service)
tool = mod.create_stop_watch_tool(
workspace_id=3, thread_id=55, auth_context=MagicMock()
)
out = await tool.ainvoke({})
assert out["status"] == "stopped"
assert sorted(out["stopped_ids"]) == [1, 2]
assert stop_service.await_count == 2
@pytest.mark.asyncio
async def test_stop_watch_reports_when_nothing_to_stop(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.agents.chat.multi_agent_chat.subagents.builtins.scraping.tools import (
stop_watch as mod,
)
monkeypatch.setattr(mod, "AutomationService", MagicMock(return_value="svc"))
monkeypatch.setattr(mod, "async_session_maker", lambda: _FakeSessionCM())
monkeypatch.setattr(mod, "find_watches_for_thread", AsyncMock(return_value=[]))
monkeypatch.setattr(mod, "stop_watch_service", AsyncMock())
tool = mod.create_stop_watch_tool(
workspace_id=3, thread_id=55, auth_context=MagicMock()
)
out = await tool.ainvoke({})
assert out["status"] == "not_watching"
@pytest.mark.asyncio
async def test_refresh_watch_runs_each_watch_now(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.agents.chat.multi_agent_chat.subagents.builtins.scraping.tools import (
refresh_watch as mod,
)
monkeypatch.setattr(mod, "AutomationService", MagicMock(return_value="svc"))
monkeypatch.setattr(mod, "async_session_maker", lambda: _FakeSessionCM())
monkeypatch.setattr(
mod, "find_watches_for_thread", AsyncMock(return_value=[_watch(7)])
)
run_now = AsyncMock()
monkeypatch.setattr(mod, "run_watch_now", run_now)
tool = mod.create_refresh_watch_tool(
workspace_id=3, thread_id=55, auth_context=MagicMock()
)
out = await tool.ainvoke({})
assert out["status"] == "refreshing"
assert out["refreshed_ids"] == [7]
run_now.assert_awaited_once()
assert run_now.await_args.kwargs["automation_id"] == 7
def test_load_tools_includes_control_tools_when_bindable() -> None:
from app.agents.chat.multi_agent_chat.subagents.builtins.scraping.tools.index import (
load_tools,
)
tools = load_tools(
dependencies={
"workspace_id": 3,
"thread_id": 55,
"auth_context": MagicMock(),
}
)
names = {t.name for t in tools}
assert {"start_watch", "stop_watch", "refresh_watch"} <= names

View file

@ -19,7 +19,7 @@ from app.agents.chat.multi_agent_chat.subagents.registry import (
pytestmark = pytest.mark.unit
# The full specialist roster the main agent composes from: 5 builtins + 15
# The full specialist roster the main agent composes from: 6 builtins + 15
# connector routes. Adding/removing a specialist is a deliberate product change
# and must be reflected here.
_EXPECTED_SUBAGENTS = frozenset(
@ -41,9 +41,10 @@ _EXPECTED_SUBAGENTS = frozenset(
"notion",
"onedrive",
"research",
"scraping",
"slack",
"teams",
"web_crawler",
"youtube",
}
)