Spaces:
Paused
Paused
Download Browser_Agent/agent/brain.py from Luca448/APP-Backend: direct link, hf CLI and curl.
- Browser
- Download file 34 kB
-
https://huggingface.co/spaces/Luca448/APP-Backend/resolve/main/Browser_Agent/agent/brain.py
- Command line
-
hf download hf://spaces/Luca448/APP-Backend/Browser_Agent/agent/brain.py
-
curl -L -o brain.py https://huggingface.co/spaces/Luca448/APP-Backend/resolve/main/Browser_Agent/agent/brain.py
34 kB
| # agent/brain.py | |
| import json | |
| import logging | |
| import asyncio | |
| import random | |
| import httpx | |
| from dataclasses import dataclass, field | |
| from playwright.async_api import Page | |
| from agent.tool_schemas import TOOLS, SYSTEM_PROMPT | |
| from agent.tools import execute_tool | |
| from agent.tool_registry import TOOL_SPECS | |
| from config.settings import settings | |
| # NOTE: Security/stealth checks can also be enforced here if necessary. | |
| logger = logging.getLogger(__name__) | |
| OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" | |
| class AgentSession: | |
| task: str | |
| messages: list = field(default_factory=list) | |
| step_count: int = 0 | |
| total_tokens: int = 0 | |
| estimated_cost_usd: float = 0.0 | |
| _LARGE_OBSERVATION_FIELDS = ("dom_text", "text_content", "image_base64") | |
| _OBSERVATION_TOOLS = { | |
| "read_dom", | |
| "read_text_content", | |
| "find_element", | |
| "extract_structured", | |
| "visual_check", | |
| "list_tabs", | |
| "search_web", | |
| } | |
| def _compact_processed_observations(messages: list[dict]) -> None: | |
| """Drop bulky page observations only after the model consumed them once.""" | |
| for index, message in enumerate(messages): | |
| if message.get("role") != "tool": | |
| continue | |
| try: | |
| decoded = json.loads(message.get("content", "{}")) | |
| except Exception: | |
| continue | |
| if not isinstance(decoded, dict) or not any( | |
| field in decoded for field in _LARGE_OBSERVATION_FIELDS | |
| ): | |
| continue | |
| compacted_fields = [] | |
| for field in _LARGE_OBSERVATION_FIELDS: | |
| value = decoded.pop(field, None) | |
| if value is not None: | |
| compacted_fields.append(f"{field}:{len(str(value))}") | |
| decoded["observation_compacted"] = ",".join(compacted_fields) | |
| decoded["message"] = "Frühere Seitenbeobachtung wurde nach Verarbeitung komprimiert." | |
| messages[index]["content"] = json.dumps( | |
| decoded, ensure_ascii=False, separators=(",", ":") | |
| ) | |
| def _compact_history_result(result: object, max_chars: int = 1200) -> str: | |
| """Keep useful prior-task context while removing transport payloads.""" | |
| import re | |
| text = str(result or "") | |
| text = re.sub(r"\[HANDOFF_REQUIRED\|.*?\]", "[Browser-Handoff angefordert]", text) | |
| text = re.sub(r"\[EPHEMERAL_IMAGE\|.*?\]", "[Bild bereitgestellt]", text) | |
| return text[:max_chars] | |
| def _tools_for_model(model: str) -> list[dict]: | |
| """Return tools whose results are safe for a text-only main model.""" | |
| return [ | |
| tool for tool in TOOLS | |
| if tool["function"]["name"] != "request_temp_mail_bypass" | |
| ] | |
| class CircuitBreakerError(Exception): | |
| """Wird geworfen wenn Circuit Breaker offen ist oder Budget überschritten.""" | |
| pass | |
| class AgentIncompleteError(RuntimeError): | |
| """The model stopped without a verified completion tool.""" | |
| pass | |
| class ModelAPIError(RuntimeError): | |
| def __init__( | |
| self, | |
| message: str, | |
| *, | |
| status_code: int | None = None, | |
| retry_after: float | None = None, | |
| ): | |
| super().__init__(message) | |
| self.status_code = status_code | |
| self.retry_after = retry_after | |
| def retryable(self) -> bool: | |
| return self.status_code is None or self.status_code in {408, 429} or bool( | |
| self.status_code and self.status_code >= 500 | |
| ) | |
| class _CircuitBreakerState: | |
| """ | |
| 3-Zustand-Automat zur Verhinderung kaskadierender Fehler (OWASP ASI08). | |
| Zustände: | |
| CLOSED → Normalbetrieb, alle Anfragen passieren. | |
| OPEN → Blockade nach max_failures aufeinanderfolgenden Fehlern. | |
| Alle Anfragen werden sofort abgelehnt (cooldown_seconds). | |
| HALF-OPEN → Nach Cooldown: Ein einzelner Probe-Request darf durch. | |
| Erfolg → CLOSED. Fehler → sofort wieder OPEN. | |
| """ | |
| def __init__(self, max_failures: int = 5, cooldown_seconds: float = 60.0): | |
| self.max_failures = max_failures | |
| self.cooldown_seconds = cooldown_seconds | |
| self._failures = 0 | |
| self._state = "CLOSED" # CLOSED | OPEN | HALF-OPEN | |
| self._opened_at: float = 0.0 | |
| self._half_open_probe_in_flight = False | |
| def record_success(self): | |
| self._failures = 0 | |
| self._state = "CLOSED" | |
| self._half_open_probe_in_flight = False | |
| def record_failure(self): | |
| self._failures += 1 | |
| if self._state == "HALF-OPEN" or self._failures >= self.max_failures: | |
| import time | |
| self._state = "OPEN" | |
| self._opened_at = time.monotonic() | |
| self._half_open_probe_in_flight = False | |
| logger.error( | |
| f"Circuit Breaker GEÖFFNET nach {self._failures} Fehlern. " | |
| f"Cooldown: {self.cooldown_seconds}s" | |
| ) | |
| def allow_request(self) -> bool: | |
| import time | |
| if self._state == "CLOSED": | |
| return True | |
| if self._state == "OPEN": | |
| elapsed = time.monotonic() - self._opened_at | |
| if elapsed >= self.cooldown_seconds: | |
| self._state = "HALF-OPEN" | |
| self._half_open_probe_in_flight = True | |
| logger.info("Circuit Breaker → HALF-OPEN: Probe-Request erlaubt") | |
| return True | |
| return False | |
| if self._state == "HALF-OPEN": | |
| if self._half_open_probe_in_flight: | |
| return False | |
| self._half_open_probe_in_flight = True | |
| return True | |
| return False | |
| def state(self) -> str: | |
| return self._state | |
| def _describe_tool_call(name: str, args: dict) -> tuple[str, str]: | |
| """Erzeugt Claude-Code-artige informative Titel und Kurzbeschreibungen für jeden Schritt.""" | |
| if name == "navigate": | |
| url = args.get("url", "") | |
| return "🌐 Seite laden", f"Navigiere zu: {url}" | |
| elif name == "search_web": | |
| q = args.get("query", "") | |
| return "🔍 Web-Suche", f'Suche nach: "{q}"' | |
| elif name == "create_temp_account": | |
| u = args.get("target_url", "") | |
| return "📧 Wegwerf-Account", f"Registrierung auf: {u}" | |
| elif name == "click": | |
| nid = args.get("node_id", "") | |
| return "👆 Element anklicken", f"Klicke Node #{nid}" | |
| elif name == "type_text": | |
| nid = args.get("node_id", "") | |
| val = str(args.get("value", "")) | |
| if any(k in str(nid).lower() for k in ["pass", "pwd", "secret", "pin"]): | |
| masked_val = "••••••••" | |
| else: | |
| masked_val = f'"{val}"' if len(val) <= 25 else f'"{val[:22]}..."' | |
| return "⌨️ Text eingeben", f"Feld #{nid}: {masked_val}" | |
| elif name == "read_dom": | |
| return "📄 Seite analysieren", "Lese Webseiten-Struktur & Accessibility Tree" | |
| elif name == "read_text_content": | |
| return "📄 Text auslesen", "Extrahiere sichtbaren Text der Webseite" | |
| elif name == "find_element": | |
| q = args.get("query", "") | |
| return "🔎 Element suchen", f'Suche nach: "{q}"' | |
| elif name == "visual_check": | |
| return "Visueller Check", str(args.get("goal", "Prüfe den aktuellen Viewport"))[:80] | |
| elif name == "generate_image_and_deliver": | |
| p = args.get("prompt", "") | |
| return "🎨 Bild generieren", f'Prompt: "{p[:40]}..."' | |
| elif name == "define_plan": | |
| steps_list = args.get("steps", []) | |
| return "📋 Plan erstellt", f"{len(steps_list)} Schritte geplant" | |
| elif name == "update_step_status": | |
| idx = args.get("step_index", 0) | |
| st = args.get("status", "") | |
| return "🔄 Status aktualisieren", f"Schritt {idx + 1} auf '{st}'" | |
| elif name == "request_user_permission": | |
| r = args.get("reason", "") | |
| return "🛡️ Erlaubnis anfragen", f"Grund: {r}" | |
| elif name == "request_browser_handoff": | |
| return "🤝 Browser Handoff", "Interaktives Login / Captcha erforderlich" | |
| elif name == "task_complete": | |
| return "✅ Fertigstellen", "Ergebnis wird aufbereitet" | |
| else: | |
| return f"⚙️ Tool: {name}", f"Argumente: {str(args)[:60]}" | |
| def _format_tool_result_summary(name: str, args: dict, result: any) -> str: | |
| """Formatiert das Ergebnis eines Werkzeugs prägnant für die UI-Timeline.""" | |
| if not isinstance(result, dict): | |
| return str(result)[:80] | |
| if result.get("error"): | |
| return f"Fehler: {str(result['error'])[:80]}" | |
| if name == "navigate": | |
| return "Seite erfolgreich geladen" | |
| elif name == "search_web": | |
| cnt = len(result.get("results", [])) | |
| return f"{cnt} Suchergebnisse gefunden" | |
| elif name == "click": | |
| return f"Klick auf Element #{args.get('node_id')} ausgeführt" | |
| elif name == "type_text": | |
| return "Text erfolgreich eingetragen" | |
| elif name == "read_dom": | |
| dom_len = len(result.get("dom_text", "")) | |
| return f"Webseite analysiert ({dom_len} Zeichen)" | |
| elif name == "read_text_content": | |
| txt_len = len(result.get("text_content", "")) | |
| return f"Text extrahiert ({txt_len} Zeichen)" | |
| elif name == "visual_check": | |
| report = result.get("visual_check") or {} | |
| return str(report.get("summary") or "Visueller Check abgeschlossen")[:80] | |
| elif name == "create_temp_account": | |
| if result.get("status") == "success": | |
| return f"Account erstellt: {result.get('email', '')}" | |
| elif result.get("status") == "handoff_required": | |
| return "Captcha erkannt - Handoff erforderlich" | |
| return str(result.get("message", "Fertig"))[:80] | |
| elif name == "generate_image_and_deliver": | |
| return "Bild erfolgreich erzeugt" | |
| return str(result.get("message", "Erfolgreich ausgeführt"))[:80] | |
| class AgentBrain: | |
| """ | |
| Kernlogik des Browser-Agenten. | |
| Koordiniert DeepSeek-Aufrufe und Tool-Ausführungen in einer Agentic Loop. | |
| Enthält Circuit Breaker (OWASP ASI08) und Token-Budget-Kontrolle. | |
| """ | |
| def __init__(self): | |
| self._client = httpx.AsyncClient(timeout=60.0) | |
| self._circuit_breakers: dict[str, _CircuitBreakerState] = {} | |
| def _breaker_for(self, model: str) -> _CircuitBreakerState: | |
| if model not in self._circuit_breakers: | |
| self._circuit_breakers[model] = _CircuitBreakerState( | |
| max_failures=5, | |
| cooldown_seconds=60.0, | |
| ) | |
| return self._circuit_breakers[model] | |
| async def _call_api_with_resilience( | |
| self, | |
| messages: list[dict], | |
| *, | |
| model: str | None, | |
| api_key: str | None, | |
| session_id: str | None, | |
| allow_fallback: bool, | |
| ) -> dict: | |
| primary = model or settings.openrouter_model | |
| models = [primary] | |
| if allow_fallback and settings.fallback_model and settings.fallback_model != primary: | |
| models.append(settings.fallback_model) | |
| last_error: Exception | None = None | |
| for model_index, candidate in enumerate(models): | |
| max_attempts = max(1, settings.model_max_attempts) if model_index == 0 else 1 | |
| breaker = self._breaker_for(candidate) | |
| for attempt in range(1, max_attempts + 1): | |
| if settings.circuit_breaker_enabled and not breaker.allow_request(): | |
| last_error = CircuitBreakerError( | |
| f"Circuit Breaker für {candidate} ist OPEN." | |
| ) | |
| break | |
| try: | |
| response = await self._call_api( | |
| messages, | |
| model=candidate, | |
| api_key=api_key, | |
| session_id=session_id, | |
| ) | |
| if not response.get("choices"): | |
| raise ModelAPIError( | |
| "Modellantwort enthält keine choices.", | |
| status_code=502, | |
| ) | |
| breaker.record_success() | |
| return response | |
| except (httpx.TransportError, httpx.TimeoutException) as exc: | |
| last_error = exc | |
| retryable = True | |
| retry_after = None | |
| except ModelAPIError as exc: | |
| last_error = exc | |
| retryable = exc.retryable | |
| retry_after = exc.retry_after | |
| except Exception as exc: | |
| last_error = exc | |
| retryable = False | |
| retry_after = None | |
| breaker.record_failure() | |
| if not retryable or attempt >= max_attempts: | |
| break | |
| delay = retry_after if retry_after is not None else (2 ** (attempt - 1)) | |
| delay += random.uniform(0, min(0.5, delay / 4)) | |
| await asyncio.sleep(delay) | |
| if last_error: | |
| raise last_error | |
| raise ModelAPIError("Kein Modell konnte die Anfrage verarbeiten.") | |
| async def run(self, instruction: str, page: Page, model: str | None = None, task=None) -> str: | |
| """ | |
| Führt eine Aufgabe durch die vollständige Agentic Loop aus. | |
| Berücksichtigt eine optionale conversation_history (Liste von {role, content} Dicts) | |
| damit der Agent sich an vorherige Aufgaben in dieser Chat-Session erinnert. | |
| """ | |
| import datetime | |
| current_time = datetime.datetime.now().strftime("%A, %Y-%m-%d %H:%M:%S") | |
| # Injiziere Konfigurationsstatus in System-Prompt, damit Agent | |
| # keine unnötigen Rückfragen stellt (z.B. ob DDG Token vorhanden) | |
| config_notes = [] | |
| duck_token_available = False | |
| if task is not None: | |
| task_params = task.params if isinstance(task.params, dict) else {} | |
| duck_from_params = task_params.get("duckduckgo_token", "") or "" | |
| duck_from_settings = getattr(settings, "duckduckgo_token", "") or "" | |
| duck_token = duck_from_params or duck_from_settings or "" | |
| duck_token_available = bool(duck_token and duck_token.strip()) | |
| logger.info(f"[DIAG] brain.run DDG token check: from_params_len={len(duck_from_params)}, from_settings_len={len(duck_from_settings)}, available={duck_token_available}") | |
| if duck_token_available: | |
| config_notes.append("- DUCKDUCKGO_TOKEN: VORHANDEN. Du kannst `create_temp_account` DIREKT aufrufen, ohne den Nutzer nach einer E-Mail-Adresse zu fragen. Das Tool erstellt automatisch einen @duck.com Alias.") | |
| else: | |
| config_notes.append("- DUCKDUCKGO_TOKEN: NICHT konfiguriert. Als Fallback wird mail.tm verwendet.") | |
| config_block = "\n".join(config_notes) | |
| dynamic_system_prompt = ( | |
| f"{SYSTEM_PROMPT}\n\n" | |
| f"[SYSTEM INFO]\n" | |
| f"Die aktuelle Serverzeit (und das heutige Datum) ist: {current_time}.\n" | |
| f"Nutze dieses Datum als absoluten Referenzpunkt für alle Ereignisse.\n\n" | |
| f"[KONFIGURATIONSSTATUS]\n{config_block}" | |
| ) | |
| session = AgentSession(task=instruction) | |
| session.messages.append({"role": "system", "content": dynamic_system_prompt}) | |
| # Chatgedächtnis: Vorherige Aufgaben aus conversation_history einbetten | |
| if task is not None: | |
| history = task.params.get("conversation_history", []) | |
| if history: | |
| # Jede vergangene Aufgabe als kompakte Zusammenfassung einbetten | |
| history_text = "\n".join( | |
| f"[Frühere Aufgabe {i+1}]\n" | |
| f"Nutzer: {str(h.get('prompt', ''))[:600]}\n" | |
| f"Ergebnis: {_compact_history_result(h.get('result', 'Kein Ergebnis'))}\n" | |
| for i, h in enumerate(history[-5:]) # Maximal die letzten 5 Tasks | |
| ) | |
| session.messages.append({ | |
| "role": "user", | |
| "content": f"[KONTEXT — vorherige Aufgaben dieser Chat-Session]\n{history_text}\n(Ende des Kontexts)" | |
| }) | |
| session.messages.append({ | |
| "role": "assistant", | |
| "content": "Verstanden. Ich habe den Kontext der vorherigen Aufgaben gelesen und werde ihn berücksichtigen." | |
| }) | |
| session.messages.append({"role": "user", "content": f"AUFGABE:\n{instruction}"}) | |
| unverified_text_count = 0 | |
| last_mutation_revision = 0 | |
| last_observation_revision = -1 | |
| # Initialer Start-Schritt für Live-Feedback | |
| if task is not None and not task.steps: | |
| task.steps.append({ | |
| "title": "Analysiere Anfrage & plane Vorgehen", | |
| "subtitle": "Agent verbindet sich mit Browser & prüft Aufgabe...", | |
| "status": "active", | |
| "time": datetime.datetime.now().strftime("%H:%M:%S") | |
| }) | |
| while session.step_count < settings.max_agent_steps: | |
| session.step_count += 1 | |
| try: | |
| logger.info(f"Schritt {session.step_count}: API Aufruf...") | |
| api_key_param = task.params.get("api_key") if task else None | |
| api_response = await self._call_api_with_resilience( | |
| session.messages, | |
| model=model, | |
| api_key=api_key_param, | |
| session_id=task.id if task else None, | |
| allow_fallback=False, | |
| ) | |
| # Every tool observation currently in the request has now | |
| # been consumed once. Compact it before any later agent turn; | |
| # new tool results are appended below and remain intact for | |
| # their first model call. | |
| _compact_processed_observations(session.messages) | |
| except Exception as e: | |
| logger.error(f"API Fehler: {e}") | |
| if task is not None and task.steps: | |
| task.steps.append({ | |
| "title": "API-Fehler", | |
| "subtitle": str(e)[:100], | |
| "status": "failed", | |
| "time": datetime.datetime.now().strftime("%H:%M:%S") | |
| }) | |
| raise e | |
| # Message history aktualisieren (mit Fehlerbehandlung für malformed responses) | |
| choices = api_response.get("choices") | |
| if not choices: | |
| # OpenRouter lieferte keine choices -> Fehler sauber melden | |
| error_detail = api_response.get("error", {}).get("message") or str(api_response)[:200] | |
| logger.error(f"OpenRouter lieferte keine 'choices': {error_detail}") | |
| if task is not None: | |
| task.steps.append({ | |
| "title": "⚠️ KI-Modell Fehler", | |
| "subtitle": f"Keine Antwort vom Modell: {error_detail[:100]}", | |
| "status": "failed", | |
| "time": datetime.datetime.now().strftime("%H:%M:%S") | |
| }) | |
| raise ValueError(f"OpenRouter Fehler – kein Ergebnis vom Modell. Details: {error_detail}") | |
| message = choices[0]["message"] | |
| session.messages.append(message) | |
| # Initialen Planungs-Schritt abschließen falls noch aktiv | |
| if task is not None and task.steps and task.steps[0].get("status") == "active": | |
| task.steps[0]["status"] = "done" | |
| # OpenRouter returns native token counts and the actual charged | |
| # cost for every response. The former implementation added the | |
| # cumulative token total again on every step, making the estimate | |
| # grow quadratically and causing false budget aborts. | |
| usage = api_response.get("usage", {}) or {} | |
| response_tokens = int(usage.get("total_tokens") or 0) | |
| prompt_tokens = int(usage.get("prompt_tokens") or 0) | |
| completion_tokens = int(usage.get("completion_tokens") or 0) | |
| reasoning_tokens = int( | |
| (usage.get("completion_tokens_details") or {}).get("reasoning_tokens") or 0 | |
| ) | |
| cached_tokens = int( | |
| (usage.get("prompt_tokens_details") or {}).get("cached_tokens") or 0 | |
| ) | |
| session.total_tokens += response_tokens | |
| response_cost = usage.get("cost") | |
| if response_cost is None: | |
| # Compatibility fallback for providers/tests that omit cost. | |
| logger.warning( | |
| "OpenRouter usage.cost fehlt; verwende konservative Token-Fallbackschätzung." | |
| ) | |
| response_cost = (response_tokens / 1000) * 0.0001 | |
| session.estimated_cost_usd += float(response_cost) | |
| logger.info( | |
| "OpenRouter usage: prompt=%d completion=%d reasoning=%d cached=%d " | |
| "total=%d cost=%.6f session_tokens=%d session_cost=%.6f", | |
| prompt_tokens, | |
| completion_tokens, | |
| reasoning_tokens, | |
| cached_tokens, | |
| response_tokens, | |
| float(response_cost), | |
| session.total_tokens, | |
| session.estimated_cost_usd, | |
| ) | |
| if session.estimated_cost_usd > settings.token_budget_usd: | |
| raise CircuitBreakerError("Token Budget überschritten!") | |
| tool_calls = message.get("tool_calls") or [] | |
| # Modell-Gedanken / Zwischenupdates als Schritt festhalten | |
| thought = (message.get("content") or "").strip() | |
| if thought and tool_calls and task is not None: | |
| task.steps.append({ | |
| "title": "💡 Überlegung", | |
| "subtitle": thought if len(thought) <= 150 else thought[:147] + "...", | |
| "status": "done", | |
| "time": datetime.datetime.now().strftime("%H:%M:%S") | |
| }) | |
| if not tool_calls: | |
| # Kein Tool-Call -> Direktes Text-Ergebnis | |
| res_content = message.get("content", "Aufgabe abgeschlossen.") | |
| if task is not None: | |
| unverified_text_count += 1 | |
| if unverified_text_count <= 2: | |
| session.messages.append({ | |
| "role": "user", | |
| "content": ( | |
| "Die Aufgabe ist noch nicht verifiziert abgeschlossen. " | |
| "Nutze ein Browser-Werkzeug oder `task_complete` mit dem " | |
| "tatsächlich geprüften Ergebnis." | |
| ), | |
| }) | |
| continue | |
| raise AgentIncompleteError( | |
| "Das Modell beendete die Aufgabe ohne verifizierten Abschluss." | |
| ) | |
| if task is not None: | |
| task.steps.append({ | |
| "title": "✅ Antwort bereitgestellt", | |
| "subtitle": "Direkte Auswertung abgeschlossen", | |
| "status": "done", | |
| "time": datetime.datetime.now().strftime("%H:%M:%S") | |
| }) | |
| return res_content | |
| # Tool-Calls ausführen | |
| for tool_call in tool_calls: | |
| func_name = tool_call["function"]["name"] | |
| # Parse arguments | |
| try: | |
| func_args = json.loads(tool_call["function"]["arguments"]) | |
| except Exception as e: | |
| logger.error(f"Fehler beim Parsen der Argumente für {func_name}: {e}") | |
| session.messages.append({ | |
| "role": "tool", | |
| "tool_call_id": tool_call["id"], | |
| "content": json.dumps({ | |
| "status": "error", | |
| "error_code": "invalid_tool_arguments", | |
| "error": "Tool-Argumente waren kein gültiges JSON.", | |
| }, ensure_ascii=False), | |
| }) | |
| continue | |
| if func_name == "task_complete": | |
| if last_observation_revision < last_mutation_revision: | |
| session.messages.append({ | |
| "role": "tool", | |
| "tool_call_id": tool_call["id"], | |
| "content": json.dumps({ | |
| "status": "error", | |
| "error_code": "verification_required", | |
| "error": ( | |
| "Beobachte den aktuellen Seitenzustand nach der letzten Aktion " | |
| "mit read_dom, read_text_content oder visual_check." | |
| ), | |
| }, ensure_ascii=False), | |
| }) | |
| continue | |
| final_result = func_args.get("result", "Aufgabe abgeschlossen.") | |
| logger.info(f"Aufgabe abgeschlossen nach {session.step_count} Schritten.") | |
| # Anti-Halluzinations-Sperre: Prüfe ob ein Fake-Account-Token vorliegt | |
| if "[ACCOUNT_CREATED|" in final_result: | |
| # Prüfe, ob in den Tool-Ergebnissen der Session ein Fehler bei create_temp_account war | |
| had_failed_creation = False | |
| for msg in session.messages: | |
| if msg.get("role") == "tool": | |
| try: | |
| t_data = json.loads(msg.get("content", "{}")) | |
| if isinstance(t_data, dict) and (t_data.get("status") == "error" or t_data.get("success") is False): | |
| had_failed_creation = True | |
| break | |
| except Exception: | |
| pass | |
| if had_failed_creation: | |
| logger.warning("Blockiere halluziniertes [ACCOUNT_CREATED|...] Token wegen fehlgeschlagenem Tool-Aufruf!") | |
| import re | |
| final_result = re.sub(r'\[ACCOUNT_CREATED\|.*?\]', '', final_result).strip() | |
| if not final_result: | |
| final_result = "Die Account-Erstellung ist leider fehlgeschlagen (die Website blockiert automatisierte Anmeldungen oder verlangt OAuth)." | |
| if task is not None: | |
| task.steps.append({ | |
| "title": "✅ Aufgabe abgeschlossen", | |
| "subtitle": "Ergebnis übermittelt", | |
| "status": "done", | |
| "time": datetime.datetime.now().strftime("%H:%M:%S") | |
| }) | |
| return final_result | |
| # Schritt in Timeline aufnehmen (außer reine interne Plan-Tools) | |
| step_entry = None | |
| if task is not None and func_name not in ["define_plan", "update_step_status"]: | |
| step_title, step_sub = _describe_tool_call(func_name, func_args) | |
| step_entry = { | |
| "title": step_title, | |
| "subtitle": step_sub, | |
| "status": "active", | |
| "time": datetime.datetime.now().strftime("%H:%M:%S") | |
| } | |
| task.steps.append(step_entry) | |
| logger.info( | |
| "Führe Tool aus: %s (Argumente: %s)", | |
| func_name, | |
| ", ".join(sorted(func_args.keys())) or "keine", | |
| ) | |
| tool_result = await execute_tool(func_name, func_args, page, task=task) | |
| tool_failed = isinstance(tool_result, dict) and ( | |
| tool_result.get("error") is not None | |
| or tool_result.get("status") == "error" | |
| or tool_result.get("success") is False | |
| ) | |
| if not tool_failed: | |
| revision = int(tool_result.get("snapshot_revision") or 0) | |
| spec = TOOL_SPECS.get(func_name) | |
| if spec and spec.invalidates_snapshot: | |
| last_mutation_revision = max(last_mutation_revision, revision) | |
| if func_name in _OBSERVATION_TOOLS: | |
| last_observation_revision = max(last_observation_revision, revision) | |
| if func_name == "visual_check": | |
| report = tool_result.get("visual_check") or {} | |
| session.estimated_cost_usd += float(report.get("costUsd") or 0.0) | |
| if session.estimated_cost_usd > settings.token_budget_usd: | |
| raise CircuitBreakerError( | |
| "Token Budget durch visuellen Check überschritten!" | |
| ) | |
| # Schritt aktualisieren | |
| if step_entry is not None: | |
| step_entry["status"] = "failed" if tool_failed else "done" | |
| step_entry["subtitle"] = _format_tool_result_summary(func_name, func_args, tool_result) | |
| # A human handoff is a terminal backend result. Returning it | |
| # directly avoids a second LLM call whose only job was to echo | |
| # a potentially very large cookie payload verbatim. | |
| if isinstance(tool_result, dict) and tool_result.get("status") == "handoff_required": | |
| handoff_signal = tool_result.get("handoff_signal") | |
| if handoff_signal: | |
| logger.info( | |
| "Browser-Handoff als strukturierter Task-Zustand zurückgegeben" | |
| ) | |
| return tool_result | |
| return tool_result | |
| if isinstance(tool_result, dict) and tool_result.get("status") == "confirmation_required": | |
| return tool_result | |
| # Tool Response an den LLM zurückgeben | |
| session.messages.append({ | |
| "role": "tool", | |
| "tool_call_id": tool_call["id"], | |
| "content": json.dumps(tool_result, ensure_ascii=False), | |
| }) | |
| raise AgentIncompleteError( | |
| f"Maximale Schrittzahl ({settings.max_agent_steps}) erreicht." | |
| ) | |
| async def _call_api( | |
| self, | |
| messages: list[dict], | |
| model: str | None = None, | |
| api_key: str | None = None, | |
| session_id: str | None = None, | |
| ) -> dict: | |
| """ | |
| Ruft die OpenRouter API mit DeepSeek auf. | |
| KRITISCH: Die Sicherheitsparameter müssen im `provider`-Objekt stehen. | |
| """ | |
| selected_model = model or settings.openrouter_model | |
| selected_tools = _tools_for_model(selected_model) | |
| payload = { | |
| "model": selected_model, | |
| "messages": messages, | |
| "tools": selected_tools, | |
| "tool_choice": "auto", | |
| "max_tokens": 65536, | |
| "temperature": 0.2, | |
| "reasoning": {"effort": "medium"}, | |
| } | |
| if session_id: | |
| payload["session_id"] = session_id | |
| # Keep every task on one exact ZDR endpoint so prompt caches survive. | |
| payload["provider"] = { | |
| "only": [settings.openrouter_provider], | |
| "allow_fallbacks": False, | |
| "data_collection": "deny", | |
| "zdr": True, | |
| } | |
| api_key = api_key or settings.openrouter_api_key | |
| if not api_key: | |
| raise ValueError("FEHLENDER API KEY: Bitte füge deinen 'OPENROUTER_API_KEY' in den Settings deines Hugging Face Spaces (unter Variables and secrets) hinzu oder gib ihn in der App ein!") | |
| message_chars = sum(len(json.dumps(message, ensure_ascii=False)) for message in messages) | |
| logger.info( | |
| "OpenRouter request: model=%s, messages=%d, tools=%d, message_chars=%d", | |
| selected_model, | |
| len(messages), | |
| len(selected_tools), | |
| message_chars, | |
| ) | |
| response = await self._client.post( | |
| OPENROUTER_URL, | |
| headers={ | |
| "Authorization": f"Bearer {api_key}", | |
| "Content-Type": "application/json", | |
| "HTTP-Referer": "https://huggingface.co/spaces/gravity-agent", | |
| "X-Title": "Gravity Browser Agent", | |
| }, | |
| json=payload, | |
| ) | |
| if response.status_code >= 400: | |
| error_body = response.text[:500] | |
| retry_after = None | |
| try: | |
| retry_after = float(response.headers.get("Retry-After", "")) | |
| except (TypeError, ValueError): | |
| retry_after = None | |
| raise ModelAPIError( | |
| f"OpenRouter API-Fehler {response.status_code}: {error_body}", | |
| status_code=response.status_code, | |
| retry_after=retry_after, | |
| ) | |
| try: | |
| payload = response.json() | |
| except Exception as exc: | |
| raise ModelAPIError( | |
| "OpenRouter lieferte ungültiges JSON.", | |
| status_code=502, | |
| ) from exc | |
| if not isinstance(payload, dict): | |
| raise ModelAPIError( | |
| "OpenRouter lieferte ein ungültiges Antwortformat.", | |
| status_code=502, | |
| ) | |
| return payload | |
| async def close(self): | |
| await self._client.aclose() | |