feat(reddit): implement Reddit scraping subagent and associated capabilities

- Added a new `reddit` subagent to scrape structured data from Reddit posts, comments, and users.
- Introduced `reddit.scrape` capability for fetching data using URLs and search queries.
- Implemented tools for scraping and parsing Reddit data, including handling pagination and rate limits.
- Created input/output models for the Reddit scraper to define request and response structures.
- Added documentation for the new Reddit scraping functionality and its usage.
- Integrated the Reddit subagent into the existing multi-agent chat framework.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-07-04 17:31:11 -07:00
parent 701f888b9e
commit ff2e5f390f
35 changed files with 510 additions and 15 deletions

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: 6 builtins + 15
# The full specialist roster the main agent composes from: 8 builtins + 15
# connector routes. Adding/removing a specialist is a deliberate product change
# and must be reflected here.
_EXPECTED_SUBAGENTS = frozenset(
@ -34,6 +34,7 @@ _EXPECTED_SUBAGENTS = frozenset(
"gmail",
"google_drive",
"google_maps",
"google_search",
"jira",
"knowledge_base",
"linear",
@ -41,6 +42,7 @@ _EXPECTED_SUBAGENTS = frozenset(
"memory",
"notion",
"onedrive",
"reddit",
"slack",
"teams",
"web_crawler",

View file

@ -0,0 +1,96 @@
"""``reddit.scrape`` executor: verb input → actor input mapping → typed items.
Boundary mocked: the proprietary scraper (injected fake). NOT mocked: the verb's
own payloadRedditScrapeInput mapping and the dictRedditItem wrapping.
"""
from __future__ import annotations
import pytest
from app.capabilities.reddit.scrape.executor import build_scrape_executor
from app.capabilities.reddit.scrape.schemas import ScrapeInput, ScrapeOutput
from app.exceptions import ForbiddenError
from app.proprietary.platforms.reddit import RedditAccessBlockedError, RedditScrapeInput
pytestmark = pytest.mark.unit
class _FakeScraper:
"""Records the actor input + limit it was called with; returns canned items."""
def __init__(self, items: list[dict]):
self._items = items
self.calls: list[tuple[RedditScrapeInput, int | None]] = []
async def __call__(
self, actor_input: RedditScrapeInput, *, limit: int | None = None
) -> list[dict]:
self.calls.append((actor_input, limit))
return self._items
async def test_maps_urls_to_start_urls_and_wraps_items():
scraper = _FakeScraper([{"dataType": "post", "id": "abc", "title": "Hello"}])
execute = build_scrape_executor(scrape_fn=scraper)
out = await execute(ScrapeInput(urls=["https://www.reddit.com/r/python/"]))
assert isinstance(out, ScrapeOutput)
assert len(out.items) == 1
assert out.items[0].id == "abc"
assert out.items[0].title == "Hello"
assert out.items[0].dataType == "post"
(actor_input, _limit) = scraper.calls[0]
assert [u.url for u in actor_input.startUrls] == ["https://www.reddit.com/r/python/"]
assert actor_input.searches == []
async def test_forwards_search_queries_and_community():
scraper = _FakeScraper([])
execute = build_scrape_executor(scrape_fn=scraper)
await execute(ScrapeInput(search_queries=["a", "b"], community="python"))
(actor_input, _limit) = scraper.calls[0]
assert actor_input.searches == ["a", "b"]
assert actor_input.searchCommunityName == "python"
assert actor_input.startUrls == []
async def test_maps_caps_and_passes_limit():
scraper = _FakeScraper([])
execute = build_scrape_executor(scrape_fn=scraper)
await execute(
ScrapeInput(
search_queries=["x"],
max_items=25,
max_posts=7,
max_comments=3,
skip_comments=True,
sort="top",
time_filter="week",
)
)
(actor_input, limit) = scraper.calls[0]
assert actor_input.maxItems == 25
assert actor_input.maxPostCount == 7
assert actor_input.maxComments == 3
assert actor_input.skipComments is True
assert actor_input.sort == "top"
assert actor_input.time == "week"
# The outer collection limit is the caller's total-item cap.
assert limit == 25
async def test_access_blocked_maps_to_forbidden():
async def _blocked(actor_input: RedditScrapeInput, *, limit: int | None = None):
raise RedditAccessBlockedError("all IPs refused")
execute = build_scrape_executor(scrape_fn=_blocked)
with pytest.raises(ForbiddenError):
await execute(ScrapeInput(search_queries=["x"]))

View file

@ -0,0 +1,51 @@
"""``reddit.scrape`` input guards: a source is required and the batch is bounded."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.capabilities.reddit.scrape.schemas import (
MAX_REDDIT_ITEMS,
MAX_REDDIT_SOURCES,
ScrapeInput,
)
pytestmark = pytest.mark.unit
def test_rejects_input_with_no_source():
with pytest.raises(ValidationError):
ScrapeInput()
def test_accepts_urls_only():
payload = ScrapeInput(urls=["https://www.reddit.com/r/python/"])
assert payload.search_queries == []
def test_accepts_search_queries_only():
payload = ScrapeInput(search_queries=["notebooklm alternative"])
assert payload.urls == []
def test_accepts_community_only():
payload = ScrapeInput(community="python")
assert payload.community == "python"
def test_defaults_and_bounds():
payload = ScrapeInput(search_queries=["x"])
assert payload.max_items == 10
assert payload.sort == "new"
assert payload.include_nsfw is True
with pytest.raises(ValidationError):
ScrapeInput(search_queries=["x"], max_items=0)
with pytest.raises(ValidationError):
ScrapeInput(search_queries=["x"], max_items=MAX_REDDIT_ITEMS + 1)
def test_rejects_more_sources_than_the_cap():
too_many = [f"https://redd.it/{i}" for i in range(MAX_REDDIT_SOURCES + 1)]
with pytest.raises(ValidationError):
ScrapeInput(urls=too_many)

View file

@ -0,0 +1,22 @@
"""The reddit namespace registers its verb as one Capability the doors/agent read."""
from __future__ import annotations
import pytest
from app.capabilities import (
reddit, # noqa: F401 — importing the namespace registers its verbs
)
from app.capabilities.core.store import get_capability
from app.capabilities.reddit.scrape.schemas import ScrapeInput, ScrapeOutput
pytestmark = pytest.mark.unit
def test_reddit_scrape_is_registered_and_free():
cap = get_capability("reddit.scrape")
assert cap.name == "reddit.scrape"
assert cap.input_schema is ScrapeInput
assert cap.output_schema is ScrapeOutput
assert cap.billing_unit is None

View file

@ -11,8 +11,8 @@ from __future__ import annotations
import json
from collections.abc import AsyncIterator
from app.proprietary.scrapers.reddit import fetch, scraper
from app.proprietary.scrapers.reddit.fetch import (
from app.proprietary.platforms.reddit import fetch, scraper
from app.proprietary.platforms.reddit.fetch import (
RedditAccessBlockedError,
_current_session,
fetch_json,

View file

@ -16,7 +16,7 @@ from pathlib import Path
import pytest
from app.proprietary.scrapers.reddit.parsers import (
from app.proprietary.platforms.reddit.parsers import (
after,
children,
flatten_comments,

View file

@ -6,8 +6,8 @@ input surface, the flat item serialization contract, and URL classification.
from __future__ import annotations
from app.proprietary.scrapers.reddit.schemas import RedditItem, RedditScrapeInput
from app.proprietary.scrapers.reddit.url_resolver import resolve_url
from app.proprietary.platforms.reddit.schemas import RedditItem, RedditScrapeInput
from app.proprietary.platforms.reddit.url_resolver import resolve_url
def test_input_has_no_auth_fields():