mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-21 20:22:22 +02:00
149 lines
7.1 KiB
Python
149 lines
7.1 KiB
Python
"""The API-first write tier, unified for the browser agent.
|
|
|
|
When a write targets a site that has a borrowed-session write adapter, route the write HERE
|
|
instead of UI puppeteering: borrow the user's live cookies, call the site's OWN write API, and
|
|
return the site's typed receipt (its own id / permalink = proof it landed). This is
|
|
deterministic (a typed success/error envelope, no captcha on the API surface, no DOM selector to
|
|
drift) and ~50-190x faster than driving the UI (measured Reddit: 271ms vs 13-52s). Adding a site
|
|
is one adapter entry; a site with no adapter falls back to the existing UI+model write path.
|
|
|
|
Live-validated end to end on Reddit (comment 271ms + reversible delete 246ms, typed receipts).
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import time
|
|
from typing import Any, Callable, Dict, FrozenSet, List, Tuple
|
|
from urllib.parse import urlparse
|
|
|
|
from pydantic import BaseModel, ConfigDict
|
|
from typeguard import typechecked
|
|
|
|
from backend.apps.agents.browser import route_write
|
|
from backend.apps.reddit_mcp_shim import reddit_writes
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WriteResult(BaseModel):
|
|
"""The typed outcome of an API-first write. `receipt` is the site's own id/permalink, the
|
|
proof the write landed (a real receipt, not a pixel guess); `ok` is False with a legible
|
|
`error` when the site's API rejected it or no session could be borrowed."""
|
|
|
|
model_config = ConfigDict(validate_assignment=True)
|
|
|
|
ok: bool
|
|
action: str
|
|
domain: str
|
|
receipt: str = ""
|
|
error: str = ""
|
|
latency_ms: int = 0
|
|
|
|
|
|
@typechecked
|
|
def p_reddit_dispatch(action: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Route a generic write action to the proven reddit_writes function; returns its receipt
|
|
dict (raises RedditError on the API's own error envelope, surfaced as ok=False upstream)."""
|
|
if action in ("comment", "reply"):
|
|
return reddit_writes.comment(str(params["parent_id"]), str(params["text"]))
|
|
if action in ("post", "submit"):
|
|
return reddit_writes.submit(
|
|
str(params["subreddit"]), str(params["title"]), str(params.get("kind", "self")),
|
|
str(params.get("text", "")), str(params.get("url", "")),
|
|
bool(params.get("nsfw", False)), bool(params.get("spoiler", False)),
|
|
bool(params.get("send_replies", True)),
|
|
)
|
|
if action == "edit":
|
|
return reddit_writes.edit(str(params["thing_id"]), str(params["text"]))
|
|
if action == "delete":
|
|
return reddit_writes.delete(str(params["thing_id"]))
|
|
raise ValueError(f"reddit adapter has no action {action!r}")
|
|
|
|
|
|
# domain -> (actions it can do via the site's own API, sync dispatch fn). A dynamic-key registry
|
|
# keyed by domain; adding a site is one row. X/others plug in the same shape once their write API
|
|
# (GraphQL queryId + ct0) is proven, replacing their current UI-driving shim.
|
|
P_ADAPTERS: Dict[str, Tuple[FrozenSet[str], Callable[[str, Dict[str, Any]], Dict[str, Any]]]] = {
|
|
"reddit.com": (frozenset({"comment", "reply", "post", "submit", "edit", "delete"}), p_reddit_dispatch),
|
|
}
|
|
|
|
|
|
@typechecked
|
|
def has_api_write(domain: str, action: str) -> bool:
|
|
"""True when this domain has a deterministic API adapter for this write action, so the agent
|
|
should route around the UI puppeteer tier."""
|
|
entry = P_ADAPTERS.get(domain.lower().strip().lstrip("."))
|
|
return bool(entry and action in entry[0])
|
|
|
|
|
|
@typechecked
|
|
def receipt_str(receipt: Dict[str, Any]) -> str:
|
|
"""Flatten a site receipt dict into the single most-proof-bearing string (permalink beats a
|
|
bare id) so callers get one legible confirmation without knowing each site's shape."""
|
|
for key in ("permalink", "url", "id"):
|
|
v = receipt.get(key)
|
|
if v:
|
|
return str(v)
|
|
return "ok"
|
|
|
|
|
|
@typechecked
|
|
def p_ensure_session_env() -> None:
|
|
"""Point session_source at the running backend so the in-process agent can borrow cookies the
|
|
same token-gated way the subprocess shims do (module globals are read at import, so patch
|
|
them). No-op once set."""
|
|
from backend.apps.social_shims import session_source as ss
|
|
port = os.environ.get("OPENSWARM_PORT", "8324")
|
|
ss.BACKEND_PORT = port
|
|
ss.BRIDGE_URL = f"http://127.0.0.1:{port}/api/browser-session/cookies"
|
|
if not ss.AUTH_TOKEN:
|
|
try:
|
|
from backend.auth import get_auth_token
|
|
ss.AUTH_TOKEN = get_auth_token() or ""
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@typechecked
|
|
async def api_route_write(origin: str, method: str, url: str, body: Dict[str, Any],
|
|
captured: List[route_write.CapturedRoute]) -> WriteResult:
|
|
"""The GENERAL tier: replay a captured mutating route the site's own UI fired, for sites with
|
|
no hand-written adapter. Wraps route_write's typed outcome into the registry's WriteResult so
|
|
callers get one shape. Every refusal/rejection is ok=False, so the agent falls back to the UI."""
|
|
p_ensure_session_env()
|
|
d = (urlparse(origin).netloc or origin).lstrip(".")
|
|
out = await asyncio.to_thread(route_write.replay_write, method, url, body, origin, captured)
|
|
return WriteResult(ok=out.ok, action="route", domain=d, receipt=out.receipt,
|
|
error=out.error, latency_ms=out.latency_ms)
|
|
|
|
|
|
@typechecked
|
|
async def api_write(domain: str, action: str, params: Dict[str, Any]) -> WriteResult:
|
|
"""Perform a write via the site's own API using the borrowed session. Times it, and turns any
|
|
failure (rejected by the site, no session, bad params) into a typed ok=False result rather
|
|
than raising, so the agent can fall back to the UI path on a miss without a crash."""
|
|
d = domain.lower().strip().lstrip(".")
|
|
entry = P_ADAPTERS.get(d)
|
|
if not entry or action not in entry[0]:
|
|
return WriteResult(ok=False, action=action, domain=d,
|
|
error=f"no API-first adapter for {d}/{action}; use the UI path")
|
|
p_ensure_session_env()
|
|
dispatch = entry[1]
|
|
t0 = time.monotonic()
|
|
try:
|
|
receipt = await asyncio.to_thread(dispatch, action, params)
|
|
return WriteResult(ok=True, action=action, domain=d,
|
|
receipt=receipt_str(receipt),
|
|
latency_ms=int((time.monotonic() - t0) * 1000))
|
|
except reddit_writes.RedditError as e:
|
|
# EXPECTED site-side reject (not logged in, rate-limited, bad params): the model sees it and falls back; info, not an alarm. (Future adapters should raise their own recognizable reject type to land here.)
|
|
logger.info(f"[api-write] {d}/{action} rejected by the site: {e}")
|
|
return WriteResult(ok=False, action=action, domain=d, error=str(e)[:200],
|
|
latency_ms=int((time.monotonic() - t0) * 1000))
|
|
except Exception as e:
|
|
# UNEXPECTED: the adapter code itself threw (a bug, or the site changed shape). It fails open to the UI, so without this WARNING a systemically-broken fast path is INVISIBLE (looks like the tier just isn't used).
|
|
logger.warning(f"[api-write] {d}/{action} adapter FAILED unexpectedly (fast path broken here): {e}")
|
|
return WriteResult(ok=False, action=action, domain=d, error=str(e)[:200],
|
|
latency_ms=int((time.monotonic() - t0) * 1000))
|