| """Shared decision helper for browser agents. |
| |
| This keeps browser action selection consistent across visual and stealth modes, |
| while allowing a gradual move from plain JSON prompting to tool calling. |
| """ |
|
|
| from __future__ import annotations |
| from typing import Any |
|
|
| from app.agents.browser_tools import ( |
| execute_browser_tool_call, |
| get_browser_tools, |
| parse_browser_json_response, |
| ) |
| from app.agents.llm_client import generate_completion, generate_completion_response |
|
|
|
|
| async def decide_browser_action( |
| *, |
| task: str, |
| current_url: str, |
| state, |
| content_preview: str, |
| blocked: bool, |
| allow_scroll: bool, |
| mode_label: str, |
| step_label: str, |
| links: list[str] | None = None, |
| max_tokens: int = 700, |
| ) -> dict[str, Any]: |
| """Ask the LLM for the next browser action using tools with JSON fallback.""" |
| memory_context = state.get_context_for_llm() |
| history_str = "\n".join([f"- {url}" for url in state.visited_urls[-8:]]) or "(none)" |
| known_str = "\n".join([f"- {fact}" for fact in state.known_facts[-6:]]) or "(none yet)" |
| missing_str = "\n".join([f"- {point}" for point in state.missing_points[-6:]]) or "(none)" |
| recent_queries_str = "\n".join([f"- {query}" for query in state.last_queries[-6:]]) or "(none)" |
| links_str = "\n".join([f"- {url}" for url in (links or [])[:10]]) or "(none)" |
|
|
| scroll_rule = ( |
| "- `scroll_page`: only if the current page likely contains the missing answer but needs more content\n" |
| if allow_scroll |
| else "" |
| ) |
|
|
| prompt = f"""You are a {mode_label} browser agent. |
| |
| Choose the single best next action for this task. Use tool calls when available. |
| If tool calling is unavailable, return valid JSON matching the same intent. |
| Never reveal chain-of-thought, hidden reasoning, deliberation, or internal analysis. |
| Never answer with prose paragraphs unless you are placing the final user-facing answer inside `finish_task` or JSON `answer`. |
| |
| TASK: {task} |
| CURRENT URL: {current_url} |
| STEP: {step_label} |
| BLOCKED: {blocked} |
| |
| MEMORY: |
| {memory_context} |
| |
| KNOWN FACTS: |
| {known_str} |
| |
| MISSING INFO: |
| {missing_str} |
| |
| RECENT QUERIES: |
| {recent_queries_str} |
| |
| VISITED URLS: |
| {history_str} |
| |
| CURRENT PAGE CONTENT: |
| {content_preview or "(empty page)"} |
| |
| DISCOVERED LINKS: |
| {links_str} |
| |
| Prefer these tools: |
| - `search_web`: when current evidence is insufficient and you need a new query |
| - `navigate_to_url`: when a discovered URL is the best next source and has not been visited |
| {scroll_rule}- `finish_task`: when you have enough evidence to answer now |
| |
| Rules: |
| 1. Do not revisit URLs already listed under VISITED URLS |
| 2. If the page is blocked, prefer search or a different navigation target immediately |
| 3. Keep `known_facts` and `missing_points` concise and session-specific |
| 4. If you finish, provide the answer in the tool arguments or JSON output |
| """ |
|
|
| response = await generate_completion_response( |
| messages=[{"role": "user", "content": prompt}], |
| max_tokens=max_tokens, |
| tools=get_browser_tools(allow_scroll=allow_scroll), |
| tool_choice="auto", |
| reasoning_effort="medium", |
| prefer_responses_api=True, |
| ) |
|
|
| if response.tool_calls: |
| last_error: Exception | None = None |
| for tool_call in response.tool_calls: |
| try: |
| return execute_browser_tool_call(tool_call, allow_scroll=allow_scroll) |
| except Exception as exc: |
| last_error = exc |
| if last_error is not None: |
| raise last_error |
|
|
| if response.content: |
| try: |
| return parse_browser_json_response(response.content, allow_scroll=allow_scroll) |
| except Exception: |
| repaired = await _repair_browser_decision_text( |
| raw_text=response.content, |
| allow_scroll=allow_scroll, |
| ) |
| if repaired is not None: |
| return repaired |
|
|
| return _fallback_browser_decision( |
| task=task, |
| current_url=current_url, |
| blocked=blocked, |
| allow_scroll=allow_scroll, |
| links=links or [], |
| raw_text=response.content, |
| ) |
|
|
| return _fallback_browser_decision( |
| task=task, |
| current_url=current_url, |
| blocked=blocked, |
| allow_scroll=allow_scroll, |
| links=links or [], |
| raw_text="", |
| ) |
|
|
|
|
| async def _repair_browser_decision_text( |
| *, |
| raw_text: str, |
| allow_scroll: bool, |
| ) -> dict[str, Any] | None: |
| """Ask the model to convert its previous free-form text into strict JSON.""" |
| prompt = f"""Convert the text below into exactly one valid JSON object for a browser action. |
| |
| Allowed actions: |
| - SEARCH with field `query` |
| - NAVIGATE with field `url` |
| {"- SCROLL with no extra field" if allow_scroll else ""} |
| - DONE with field `answer` |
| |
| Rules: |
| 1. Output JSON only |
| 2. Do not include reasoning |
| 3. Keep `reason`, `known_facts`, and `missing_points` concise |
| 4. If the text is only internal reasoning and not a final answer, prefer SEARCH, NAVIGATE, or {"SCROLL" if allow_scroll else "SEARCH"} over DONE |
| |
| TEXT: |
| {raw_text} |
| """ |
| try: |
| repaired_text = await generate_completion( |
| messages=[{"role": "user", "content": prompt}], |
| max_tokens=300, |
| ) |
| return parse_browser_json_response(repaired_text, allow_scroll=allow_scroll) |
| except Exception: |
| return None |
|
|
|
|
| def _fallback_browser_decision( |
| *, |
| task: str, |
| current_url: str, |
| blocked: bool, |
| allow_scroll: bool, |
| links: list[str], |
| raw_text: str, |
| ) -> dict[str, Any]: |
| """Choose a safe next action when the model fails to return structured output.""" |
| unseen_links = [url for url in links if url.startswith("http")] |
| if unseen_links: |
| return { |
| "action": "NAVIGATE", |
| "value": unseen_links[0], |
| "answer": "", |
| "reason": "Fallback navigation because the model returned unstructured output", |
| "known_facts": [], |
| "missing_points": [], |
| } |
|
|
| if allow_scroll and not blocked and current_url.startswith("http"): |
| return { |
| "action": "SCROLL", |
| "value": "", |
| "answer": "", |
| "reason": "Fallback scroll because the model returned unstructured output", |
| "known_facts": [], |
| "missing_points": [], |
| } |
|
|
| query = task.strip() |
| if current_url.startswith("https://html.duckduckgo.com/"): |
| query = f"{task.strip()} answer" |
| elif blocked: |
| query = f"{task.strip()} alternate source" |
|
|
| return { |
| "action": "SEARCH", |
| "value": query, |
| "answer": "", |
| "reason": "Fallback search because the model returned unstructured output", |
| "known_facts": [], |
| "missing_points": [], |
| } |
|
|