# agent/tools.py import logging import asyncio import hashlib import json import uuid from datetime import datetime, timedelta, timezone from typing import Dict, Any from urllib.parse import urlparse from playwright.async_api import Page from agent.handoff import build_handoff_result, normalize_handoff_url from agent.tool_registry import TOOL_SPECS from agent.vision_subagent import VisionCheckError, vision_subagent logger = logging.getLogger(__name__) def _page_revision(page: Page) -> int: return int(getattr(page, "__dict__", {}).get("_gravity_snapshot_revision", 0)) def _advance_revision(page: Page) -> int: revision = _page_revision(page) + 1 setattr(page, "_gravity_snapshot_revision", revision) return revision def _stale_snapshot(page: Page, func_args: Dict[str, Any]) -> bool: supplied = func_args.get("snapshot_revision") return supplied is not None and int(supplied) != _page_revision(page) async def _human_mouse_move(page: Page, x: float, y: float) -> None: """Move through Playwright with a short, bounded human-like path.""" await page.mouse.move(x, y, steps=6) async def _human_type(page: Page, value: str, selector: str) -> None: """Focus and fill an annotated field without Playwright's 30s default wait.""" field = page.locator(selector) await field.click(timeout=3000) await field.fill(value, timeout=3000) async def _execute_tool_impl(func_name: str, func_args: Dict[str, Any], page: Page, task=None) -> Dict[str, Any]: """ Führt ein Tool basierend auf den von der AI gelieferten Argumenten aus. """ try: if func_name == "navigate": url = func_args.get("url") if not url: return {"error": "Missing 'url' argument."} # --- NEU: SSRF Validation --- try: from security.ssrf_guard import validate_url_async url = await validate_url_async(url) except Exception as e: # SecurityError (oder Import Fehler) loggen und Agent blockieren logger.warning(f"Sicherheitsverstoß beim Navigieren zu {url}: {e}") return {"error": f"Sicherheitsverstoß blockiert: {e}"} # Attached sessions are origin-scoped. Never spray every cookie # from the chat session at an unrelated destination. if task is not None and isinstance(getattr(task, "params", None), dict): host = (urlparse(url).hostname or "").lower() injected = set(task.params.get("_injected_cookie_origins") or []) if host not in injected: scoped_cookies = [] for site in task.params.get("exported_sites") or []: if not isinstance(site, dict): continue domain = str(site.get("domain") or "").lstrip(".").lower() if not domain or (host != domain and not host.endswith(f".{domain}")): continue for cookie in site.get("cookies") or []: if not isinstance(cookie, dict) or not cookie.get("name"): continue normalized = { "name": str(cookie.get("name")), "value": str(cookie.get("value", "")), "domain": str(cookie.get("domain") or domain), "path": str(cookie.get("path") or "/"), } if "isSecure" in cookie or "secure" in cookie: normalized["secure"] = bool(cookie.get("isSecure", cookie.get("secure"))) if "isHttpOnly" in cookie or "httpOnly" in cookie: normalized["httpOnly"] = bool(cookie.get("isHttpOnly", cookie.get("httpOnly"))) scoped_cookies.append(normalized) if scoped_cookies: await page.context.add_cookies(scoped_cookies) injected.add(host) task.params["_injected_cookie_origins"] = sorted(injected) logger.info(f"Tool: Navigating to {url}") await page.goto(url, wait_until="domcontentloaded", timeout=30000) revision = _advance_revision(page) return {"status": "success", "message": f"Navigated to {url}", "snapshot_revision": revision} elif func_name == "go_back": await page.go_back(wait_until="domcontentloaded", timeout=30000) return {"status": "success", "snapshot_revision": _advance_revision(page)} elif func_name == "go_forward": await page.go_forward(wait_until="domcontentloaded", timeout=30000) return {"status": "success", "snapshot_revision": _advance_revision(page)} elif func_name == "reload": await page.reload(wait_until="domcontentloaded", timeout=30000) return {"status": "success", "snapshot_revision": _advance_revision(page)} elif func_name == "wait_for": selector = str(func_args.get("selector") or "") timeout_ms = min(max(int(func_args.get("timeout_ms") or 5000), 100), 15000) if selector: await page.locator(selector).first.wait_for(state="visible", timeout=timeout_ms) else: await page.wait_for_load_state("networkidle", timeout=timeout_ms) return {"status": "success", "snapshot_revision": _page_revision(page)} elif func_name == "request_user_permission": reason = func_args.get("reason") if not reason: return {"error": "Missing 'reason' argument."} if task is None or not isinstance(getattr(task, "params", None), dict): return {"error": "No task context available for confirmation."} logger.info(f"Tool: Requesting permission: {reason}") confirmation_id = f"confirm_{uuid.uuid4().hex}" page_url = str(page.url) parsed = urlparse(page_url) origin = f"{parsed.scheme}://{parsed.netloc}" expires_at = datetime.now(timezone.utc) + timedelta(minutes=5) digest = hashlib.sha256( f"{origin}|submit|{reason}".encode("utf-8") ).hexdigest() task.params["_confirmation_checkpoint"] = { "id": confirmation_id, "url": page_url, "cookies": await page.context.cookies(), "origin": origin, "action_digest": digest, "expires_at": expires_at.isoformat(), } return { "status": "confirmation_required", "message": reason, "confirmation": { "id": confirmation_id, "reason": reason, "origin": origin, "risk": "external_effect", "action": "submit", "action_digest": digest, "expires_at": expires_at.isoformat(), }, } elif func_name == "request_temp_mail_bypass": email = func_args.get("email_address", "") if not email: return {"error": "Missing 'email_address' argument."} logger.info("Deprecated temp-mail mutation bypass was rejected") return { "status": "error", "error_code": "confirmation_required", "error": ( "Auch Registrierungen mit temporärer E-Mail benötigen vor dem " "Absenden eine konkrete Nutzerbestätigung." ), } elif func_name == "request_browser_handoff": reason = func_args.get("reason", "Handoff requested") cookies = await page.context.cookies() url = normalize_handoff_url(str(page.url)) account_context = None if task is not None and isinstance(getattr(task, "params", None), dict): candidate = task.params.get("_account_creation_context") if isinstance(candidate, dict): account_context = candidate url = normalize_handoff_url( str(candidate.get("target_url") or page.url) ) form_data = None if account_context: form_data = { "email": str(account_context.get("email", "")), "username": str(account_context.get("username", "")), "password": str(account_context.get("password", "")), } logger.info(f"Tool: Browser Handoff requested for {url}") result = build_handoff_result( url, cookies, reason, form_data=form_data, handoff_type=( "registration_captcha" if account_context else "browser_challenge" ), completion_policy=( "registration_submitted" if account_context else "challenge_completed" ), ) logger.info( "Browser-Handoff vorbereitet: %d von %d Cookies übernommen", result["cookie_count"], len(cookies), ) return result elif func_name == "inject_cookies": cookies_json = func_args.get("cookies_json", "[]") resume_url = func_args.get("resume_url", "") import json try: cookies = json.loads(cookies_json) await page.context.add_cookies(cookies) if resume_url: await page.goto(resume_url, wait_until="domcontentloaded") return {"status": "success", "message": "Cookies erfolgreich injiziert. Session wiederhergestellt."} except Exception as e: return {"error": f"Fehler beim Injizieren der Cookies: {e}"} elif func_name == "search_web": query = func_args.get("query") if not query: return {"error": "Missing 'query' argument."} logger.info(f"Tool: Searching web for: {query}") try: from duckduckgo_search import DDGS with DDGS() as ddgs: results = list(ddgs.text(query, max_results=3)) return {"status": "success", "results": results} except Exception as e: logger.error(f"Search error: {e}") return {"error": f"Search failed: {e}"} elif func_name == "create_temp_account": target_url = func_args.get("target_url") if not target_url: return {"error": "Missing 'target_url' argument."} provided_email = func_args.get("provided_email", "").strip() or None logger.info(f"Tool: Creating temp account for {target_url} (provided_email={provided_email})") try: from workflows.account_creator import AccountCreatorWorkflow workflow = AccountCreatorWorkflow(page) result = await workflow.run(target_url, task=task, provided_email=provided_email) if not result.get("success", False) and result.get("status") != "handoff_required": return { "status": "error", "success": False, "error": result.get("error", "Registrierung fehlgeschlagen."), "suggestion": result.get("suggestion", "Bitte nutze Google/GitHub OAuth oder eine echte E-Mail."), "email": result.get("email", "") } return result except Exception as e: logger.error(f"Error in create_temp_account workflow: {e}") return {"status": "error", "success": False, "error": f"Workflow failed: {e}"} elif func_name == "click": node_id = func_args.get("node_id") if not node_id: return {"error": "Missing 'node_id' argument."} if _stale_snapshot(page, func_args): return { "status": "error", "error_code": "stale_snapshot", "error": "Die Seite hat sich geändert. Beobachte sie neu und versuche den Klick einmal erneut.", } logger.info(f"Tool: Clicking node {node_id}") selector = f"[data-ax-node-id='{node_id}']" try: is_form_submit = await page.evaluate(f'''() => {{ const el = document.querySelector("{selector}"); if (!el) return false; // 1. Ist es ein Submit Button? if (el.tagName.toLowerCase() === 'button' && el.type === 'submit') return true; if (el.tagName.toLowerCase() === 'input' && el.type === 'submit') return true; // 2. Ist es in einer Form und sendet diese durch Klick ab? const form = el.closest('form'); if (form && el.tagName.toLowerCase() === 'button') return true; // 3. Text Heuristik (SPA Fallback) const text = (el.textContent || el.value || el.ariaLabel || "").toLowerCase(); const submitWords = ['submit', 'login', 'anmelden', 'register', 'registrieren', 'kaufen', 'senden']; if (submitWords.some(word => text.includes(word))) return true; return false; }}''') except Exception as e: logger.warning(f"Error during submit check: {e}") is_form_submit = False approved = None confirmation_id = str(func_args.get("confirmation_id") or "") if task is not None and isinstance(getattr(task, "params", None), dict): candidate = task.params.get("_approved_confirmation") if isinstance(candidate, dict): approved = candidate if is_form_submit: parsed = urlparse(str(page.url)) current_origin = f"{parsed.scheme}://{parsed.netloc}" expires_at = None if approved and approved.get("expires_at"): try: expires_at = datetime.fromisoformat(str(approved["expires_at"])) except ValueError: expires_at = None valid_confirmation = bool( approved and confirmation_id and confirmation_id == approved.get("id") and approved.get("origin") == current_origin and approved.get("consumed") is not True and expires_at and expires_at > datetime.now(timezone.utc) ) if not valid_confirmation: return { "status": "error", "error_code": "confirmation_required", "error": ( "Diese Aktion würde ein Formular absenden. Fordere zuerst " "eine konkrete Nutzerbestätigung an." ), } # Human-like mouse interaction try: box = await page.evaluate(f'''() => {{ const el = document.querySelector("{selector}"); if (!el) return null; const r = el.getBoundingClientRect(); return {{ x: r.x + r.width/2, y: r.y + r.height/2 }}; }}''') if box and box.get("x") is not None and box.get("y") is not None: await _human_mouse_move(page, box["x"], box["y"]) await page.mouse.click(box["x"], box["y"]) else: return { "error": ( f"Element '{node_id}' ist nicht mehr vorhanden oder sichtbar. " "Lies den DOM erneut und nutze die aktuelle id." ) } except Exception as exc: return {"error": f"Klick auf Element '{node_id}' fehlgeschlagen: {exc}"} if is_form_submit and approved: approved["consumed"] = True task.params.pop("_approved_confirmation", None) return { "status": "success", "message": f"Clicked element {node_id}", "snapshot_revision": _advance_revision(page), } elif func_name == "define_plan": steps = func_args.get("steps", []) if not task: return {"error": "No task context available to define plan."} now_str = datetime.now().strftime("%H:%M:%S") task.steps = [ { "title": s, "subtitle": "Warte auf Ausführung..." if i > 0 else "Wird ausgeführt...", "status": "active" if i == 0 else "pending", "time": now_str } for i, s in enumerate(steps) ] logger.info(f"Tool: Plan definiert mit {len(steps)} Schritten (Schritt 1 aktiv)") return {"status": "success", "message": f"Plan mit {len(steps)} Schritten definiert. Schritt 1 ist jetzt aktiv."} elif func_name == "update_step_status": index = func_args.get("step_index") status = func_args.get("status") if not task or not task.steps: return {"error": "No plan defined."} if index is None or index < 0 or index >= len(task.steps): return {"error": "Invalid step_index."} task.steps[index]["status"] = status # Falls ein Schritt auf done gesetzt wird und der nächste noch pending ist, setze den nächsten auf active if status == "done" and index + 1 < len(task.steps) and task.steps[index + 1].get("status") == "pending": task.steps[index + 1]["status"] = "active" task.steps[index + 1]["subtitle"] = "Wird ausgeführt..." logger.info(f"Tool: Schritt {index} auf {status} gesetzt") return {"status": "success", "message": f"Schritt {index} aktualisiert."} elif func_name == "task_complete": result = func_args.get("result", "") logger.info(f"Tool: Task completed with result: {result[:50]}") return {"status": "success", "message": "Task complete."} elif func_name == "type_text": node_id = func_args.get("node_id") value = func_args.get("value") if not node_id or not value: return {"error": "Missing 'node_id' or 'value' argument."} if _stale_snapshot(page, func_args): return { "status": "error", "error_code": "stale_snapshot", "error": "Das Eingabefeld stammt aus einem alten Seitensnapshot. Beobachte die Seite neu.", } logger.info(f"Tool: Typing into node {node_id}") selector = f"[data-ax-node-id='{node_id}']" # Human-like typing with natural jitter try: await _human_type(page, value, selector=selector) except Exception as e: logger.warning(f"Human typing fallback: {e}") await page.fill(selector, value, timeout=3000) return { "status": "success", "message": f"Typed text into {node_id}", "snapshot_revision": _advance_revision(page), } elif func_name == "select_option": node_id = str(func_args.get("node_id") or "") value = str(func_args.get("value") or "") if not node_id or not value: return {"error": "Missing 'node_id' or 'value' argument."} if _stale_snapshot(page, func_args): return {"status": "error", "error_code": "stale_snapshot", "error": "Observe the page again."} selected = await page.locator(f"[data-ax-node-id='{node_id}']").select_option(value=value, timeout=3000) return {"status": "success", "selected": selected, "snapshot_revision": _advance_revision(page)} elif func_name == "set_checked": node_id = str(func_args.get("node_id") or "") checked = func_args.get("checked") is True if not node_id: return {"error": "Missing 'node_id' argument."} if _stale_snapshot(page, func_args): return {"status": "error", "error_code": "stale_snapshot", "error": "Observe the page again."} locator = page.locator(f"[data-ax-node-id='{node_id}']") await locator.set_checked(checked, timeout=3000) return {"status": "success", "checked": checked, "snapshot_revision": _advance_revision(page)} elif func_name == "press_key": key = str(func_args.get("key") or "") node_id = str(func_args.get("node_id") or "") if not key: return {"error": "Missing 'key' argument."} if node_id: await page.locator(f"[data-ax-node-id='{node_id}']").press(key, timeout=3000) else: await page.keyboard.press(key) return {"status": "success", "snapshot_revision": _advance_revision(page)} elif func_name == "hover": node_id = str(func_args.get("node_id") or "") if not node_id: return {"error": "Missing 'node_id' argument."} await page.locator(f"[data-ax-node-id='{node_id}']").hover(timeout=3000) return {"status": "success", "snapshot_revision": _page_revision(page)} elif func_name == "scroll": delta_y = min(max(int(func_args.get("delta_y") or 600), -4000), 4000) await page.mouse.wheel(0, delta_y) return {"status": "success", "snapshot_revision": _page_revision(page)} elif func_name == "list_tabs": pages = page.context.pages return { "status": "success", "tabs": [ {"index": index, "url": tab.url, "title": await tab.title()} for index, tab in enumerate(pages) ], } elif func_name == "switch_tab": index = int(func_args.get("index", -1)) pages = page.context.pages if index < 0 or index >= len(pages): return {"status": "error", "error_code": "tab_not_found", "error": "Tab index is invalid."} target = pages[index] await target.bring_to_front() root_page = getattr(page, "__dict__", {}).get("_gravity_root_page", page) setattr(target, "_gravity_root_page", root_page) setattr(root_page, "_gravity_active_page", target) return {"status": "success", "url": target.url, "snapshot_revision": _advance_revision(target)} elif func_name == "close_tab": index = int(func_args.get("index", -1)) pages = page.context.pages if index < 0 or index >= len(pages) or pages[index] is page: return {"status": "error", "error_code": "tab_not_closable", "error": "The requested tab cannot be closed."} await pages[index].close() return {"status": "success", "remaining_tabs": len(page.context.pages)} elif func_name == "extract_structured": fields = func_args.get("fields") or {} if not isinstance(fields, dict) or not fields: return {"error": "Missing 'fields' selector map."} extracted = {} for name, selector in list(fields.items())[:25]: try: locator = page.locator(str(selector)).first extracted[str(name)] = (await locator.inner_text(timeout=3000)).strip() except Exception: extracted[str(name)] = None return {"status": "success", "data": extracted, "url": page.url} elif func_name == "upload_file": node_id = str(func_args.get("node_id") or "") file_path = str(func_args.get("file_path") or "") confirmation_id = str(func_args.get("confirmation_id") or "") approved = task.params.get("_approved_confirmation") if task and isinstance(task.params, dict) else None if not approved or confirmation_id != approved.get("id"): return {"status": "error", "error_code": "confirmation_required", "error": "Uploading data requires user confirmation."} from pathlib import Path from config.settings import settings safe_root = settings.jail_root.resolve() resolved = Path(file_path).resolve() if not resolved.is_relative_to(safe_root) or not resolved.is_file(): return {"status": "error", "error_code": "invalid_upload_path", "error": "Upload path is outside the workspace."} await page.locator(f"[data-ax-node-id='{node_id}']").set_input_files(str(resolved), timeout=5000) task.params.pop("_approved_confirmation", None) return {"status": "success", "filename": resolved.name, "snapshot_revision": _advance_revision(page)} elif func_name == "download_file": node_id = str(func_args.get("node_id") or "") if not node_id: return {"error": "Missing 'node_id' argument."} async with page.expect_download(timeout=15000) as download_info: await page.locator(f"[data-ax-node-id='{node_id}']").click(timeout=3000) download = await download_info.value return { "status": "success", "filename": download.suggested_filename, "url": download.url, "snapshot_revision": _advance_revision(page), } elif func_name == "visual_check": goal = str(func_args.get("goal") or "").strip() if not goal: return {"status": "error", "error_code": "missing_goal", "error": "Missing 'goal' argument."} logger.info("Tool: Running ephemeral visual check") try: await page.wait_for_load_state("domcontentloaded", timeout=5000) except Exception: pass try: await page.evaluate( "async () => { if (document.fonts?.ready) await document.fonts.ready; " "await new Promise(requestAnimationFrame); await new Promise(requestAnimationFrame); }" ) except Exception: pass screenshot_bytes = await page.screenshot( type="jpeg", quality=75, full_page=False, animations="disabled", ) try: report = await vision_subagent.analyze([screenshot_bytes], goal) except VisionCheckError as exc: return { "status": "error", "error_code": "visual_check_failed", "error": str(exc), "retryable": exc.retryable, } report["snapshot_revision"] = _page_revision(page) return {"status": "success", "visual_check": report} elif func_name == "read_dom": logger.info("Tool: Reading compressed DOM") from browser.accessibility import extract_dom_text dom_text = await extract_dom_text(page) return { "status": "success", "dom_text": dom_text, "snapshot_revision": _advance_revision(page), "url": page.url, "title": await page.title(), } elif func_name == "read_text_content": logger.info("Tool: Reading full text content") try: # Playwright's innerText extracts human-readable text text = await page.evaluate("document.body.innerText") # Truncate to avoid blowing up the context completely (32000 chars is ~8k tokens) text = (text or "").strip()[:32000] return { "status": "success", "text_content": text, "snapshot_revision": _page_revision(page), } except Exception as e: return {"error": f"Error reading text: {e}"} elif func_name == "find_element": query = func_args.get("query", "") if not query: return {"error": "Missing 'query' argument."} logger.info(f"Tool: Finding element for query '{query}'") import json # Escaping for JS q_escaped = query.replace("'", "\\'").lower() js = f''' (function(){{ var q = '{q_escaped}'; var els = Array.from(document.querySelectorAll("a,button,input,textarea,select,[role=button],[role=checkbox],[tabindex]")); window.__gravityAxNextId = window.__gravityAxNextId || 0; els.forEach((el) => {{ if (!el.getAttribute('data-ax-node-id')) {{ el.setAttribute('data-ax-node-id', 'ax-' + window.__gravityAxNextId++); }} }}); var found = els.filter(el => {{ var r = el.getBoundingClientRect(); var style = getComputedStyle(el); var text = el.getAttribute('aria-label') || el.name || el.textContent || el.value || el.placeholder || ""; return style.display !== 'none' && style.visibility !== 'hidden' && r.width > 0 && r.height > 0 && text.toLowerCase().includes(q); }}); return JSON.stringify(found.slice(0, 5).map(el => {{ var r = el.getBoundingClientRect(); var text = el.getAttribute('aria-label') || el.name || el.textContent || el.value || el.placeholder || ""; return {{ node_id: el.getAttribute('data-ax-node-id'), tag: el.tagName, text: text.trim().substring(0, 60), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) }}; }})); }})() ''' try: result_json = await page.evaluate(js) elements = json.loads(result_json) if result_json else [] return {"status": "success", "elements": elements} except Exception as e: return {"error": f"Error finding element: {e}"} elif func_name == "generate_image_and_deliver": prompt = func_args.get("prompt", "") service = func_args.get("service", "felo") if not prompt: return {"error": "Missing 'prompt' argument."} from workflows.account_manager import account_manager from workflows.ephemeral_media import ephemeral_media_store logger.info(f"Tool: Starte Bilderzeugung via {service} für '{prompt}'...") result = await account_manager.execute_task_with_auto_rotation(service, page, prompt) if result.success and result.media_bytes: media_id = ephemeral_media_store.put( media_bytes=result.media_bytes, media_type=result.media_type, filename=result.filename, ttl_seconds=900, # 15 Minuten flüchtig im RAM ) media_signal = f"[EPHEMERAL_IMAGE|{media_id}|{result.filename}|{prompt}]" return { "status": "success", "media_id": media_id, "signal": media_signal, "message": f"Bild erfolgreich generiert! Sende folgendes Signal an den Nutzer:\n\n{media_signal}\n\n{result.text_response}" } else: return { "status": "error", "error": result.error_message or "Bilderzeugung fehlgeschlagen." } else: return {"error": f"Unknown tool: {func_name}"} except Exception as e: logger.error(f"Error executing tool {func_name}: {e}") return {"error": str(e)} async def execute_tool( func_name: str, func_args: Dict[str, Any], page: Page, task=None, ) -> Dict[str, Any]: """Execute a registered tool with a bounded timeout and stable outcome.""" spec = TOOL_SPECS.get(func_name) if not spec: return { "status": "error", "outcome": "fatal", "error_code": "unknown_tool", "error": f"Unknown tool: {func_name}", } last_result: Dict[str, Any] = {} for attempt in range(1, spec.max_attempts + 1): try: root_page = getattr(page, "__dict__", {}).get("_gravity_root_page", page) active_page = getattr(root_page, "__dict__", {}).get( "_gravity_active_page", root_page, ) result = await asyncio.wait_for( _execute_tool_impl(func_name, func_args, active_page, task=task), timeout=spec.timeout_seconds, ) except asyncio.TimeoutError: result = { "status": "error", "error_code": "tool_timed_out", "error": f"{func_name} exceeded its {spec.timeout_seconds:.0f}s timeout.", } last_result = dict(result or {}) is_error = bool( last_result.get("error") or last_result.get("status") == "error" or last_result.get("success") is False ) if not is_error: last_result.setdefault("outcome", "success") last_result.setdefault("tool", func_name) last_result.setdefault("attempt", attempt) return last_result if not spec.idempotent or attempt >= spec.max_attempts: break await asyncio.sleep(0.15 * attempt) last_result.setdefault("status", "error") last_result.setdefault("outcome", "retryable_error" if spec.idempotent else "fatal") last_result.setdefault("error_code", "tool_failed") last_result.setdefault("tool", func_name) return last_result