mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
refactor(subagents): split scraping into web_crawler + youtube builtins
This commit is contained in:
parent
3312a442f3
commit
5da624399d
25 changed files with 239 additions and 808 deletions
|
|
@ -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"}),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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>
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""Intelligence-agent tools: the web.* capability verbs, generated from the registry."""
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""``web_crawler`` builtin subagent: crawl single URLs or spider whole sites."""
|
||||
|
|
@ -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(
|
||||
|
|
@ -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.
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""``youtube`` builtin subagent: structured YouTube video/channel/comment data."""
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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).
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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}
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue