| """Reusable tooling for browser-agent action selection. |
| |
| This module gives the browser agents a shared, validated action surface. |
| It supports both: |
| - native tool calling when the LLM/provider can emit tool calls |
| - JSON fallback when the model only returns plain text |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| from typing import Any |
|
|
| from app.agents.tooling import ToolCall, ToolRegistry, tool |
|
|
|
|
| def _normalize_points(values: list[str] | str | None) -> list[str]: |
| """Trim and deduplicate short research memory lists. |
| |
| Models occasionally return a plain string instead of a list. Treat that as a |
| single item instead of iterating character-by-character. |
| """ |
| cleaned: list[str] = [] |
| if values is None: |
| iterable: list[Any] = [] |
| elif isinstance(values, str): |
| iterable = [values] |
| else: |
| iterable = list(values) |
|
|
| for value in iterable: |
| text = str(value).strip() |
| if len(text) <= 1 and text.isalpha(): |
| continue |
| if text and text not in cleaned: |
| cleaned.append(text) |
| return cleaned |
|
|
|
|
| @tool(description="Search the web with a fresh query when the current page is insufficient.") |
| def search_web( |
| query: str, |
| reason: str = "", |
| known_facts: list[str] | None = None, |
| missing_points: list[str] | None = None, |
| ) -> dict[str, Any]: |
| """Search the web. |
| |
| Args: |
| query: Query terms to search for next. |
| reason: Why a new search is needed. |
| known_facts: Short facts already established. |
| missing_points: What information is still missing. |
| """ |
| return { |
| "action": "SEARCH", |
| "value": query.strip(), |
| "reason": reason.strip(), |
| "known_facts": _normalize_points(known_facts), |
| "missing_points": _normalize_points(missing_points), |
| } |
|
|
|
|
| @tool(description="Open a new URL that was discovered in the current page or results.") |
| def navigate_to_url( |
| url: str, |
| reason: str = "", |
| known_facts: list[str] | None = None, |
| missing_points: list[str] | None = None, |
| ) -> dict[str, Any]: |
| """Navigate to a specific URL. |
| |
| Args: |
| url: Absolute URL to visit next. |
| reason: Why that URL is the best next step. |
| known_facts: Short facts already established. |
| missing_points: What information is still missing. |
| """ |
| return { |
| "action": "NAVIGATE", |
| "value": url.strip(), |
| "reason": reason.strip(), |
| "known_facts": _normalize_points(known_facts), |
| "missing_points": _normalize_points(missing_points), |
| } |
|
|
|
|
| @tool(description="Scroll the current page to reveal more content.") |
| def scroll_page( |
| reason: str = "", |
| known_facts: list[str] | None = None, |
| missing_points: list[str] | None = None, |
| ) -> dict[str, Any]: |
| """Scroll the current page. |
| |
| Args: |
| reason: Why scrolling is useful right now. |
| known_facts: Short facts already established. |
| missing_points: What information is still missing. |
| """ |
| return { |
| "action": "SCROLL", |
| "value": "", |
| "reason": reason.strip(), |
| "known_facts": _normalize_points(known_facts), |
| "missing_points": _normalize_points(missing_points), |
| } |
|
|
|
|
| @tool(description="Finish the task and provide the final answer based on the collected evidence.") |
| def finish_task( |
| answer: str, |
| reason: str = "", |
| known_facts: list[str] | None = None, |
| missing_points: list[str] | None = None, |
| ) -> dict[str, Any]: |
| """Finish the task. |
| |
| Args: |
| answer: Final user-facing answer. |
| reason: Why the task is complete. |
| known_facts: Short facts already established. |
| missing_points: Remaining uncertainty, if any. |
| """ |
| return { |
| "action": "DONE", |
| "value": "", |
| "answer": answer.strip(), |
| "reason": reason.strip(), |
| "known_facts": _normalize_points(known_facts), |
| "missing_points": _normalize_points(missing_points), |
| } |
|
|
|
|
| BROWSER_TOOL_REGISTRY = ToolRegistry([ |
| search_web, |
| navigate_to_url, |
| scroll_page, |
| finish_task, |
| ]) |
|
|
|
|
| def get_browser_tools(allow_scroll: bool = True) -> list[dict[str, Any]]: |
| """Return OpenAI-compatible tool schemas for the browser agent.""" |
| tools = [] |
| for schema in BROWSER_TOOL_REGISTRY.schemas: |
| if not allow_scroll and schema.name == "scroll_page": |
| continue |
| tools.append(schema.to_openai_tool()) |
| return tools |
|
|
|
|
| def execute_browser_tool_call(tool_call: ToolCall, allow_scroll: bool = True) -> dict[str, Any]: |
| """Execute a browser decision tool call and validate mode-specific constraints.""" |
| if not allow_scroll and tool_call.name == "scroll_page": |
| raise ValueError("scroll_page is not allowed for this browser mode") |
|
|
| result = BROWSER_TOOL_REGISTRY.execute(tool_call) |
| return validate_browser_decision(result, allow_scroll=allow_scroll) |
|
|
|
|
| def parse_browser_json_response(text: str, allow_scroll: bool = True) -> dict[str, Any]: |
| """Parse legacy JSON action output into the normalized browser-decision shape.""" |
| snippet = _extract_json_object(text) |
| data = json.loads(snippet) |
|
|
| action = str(data.get("action", "DONE")).strip().upper() |
| normalized = { |
| "action": action, |
| "value": str(data.get("value", "")).strip(), |
| "answer": str(data.get("answer", "")).strip(), |
| "reason": str(data.get("reason", "")).strip(), |
| "known_facts": _normalize_points(data.get("known_facts")), |
| "missing_points": _normalize_points(data.get("missing_points")), |
| } |
|
|
| if action == "SEARCH": |
| normalized["value"] = str(data.get("query", normalized["value"])).strip() |
| elif action == "NAVIGATE": |
| normalized["value"] = str(data.get("url", normalized["value"])).strip() |
| elif action == "DONE": |
| normalized["answer"] = str(data.get("answer", data.get("result", normalized["answer"]))).strip() |
| elif action == "SCROLL": |
| normalized["value"] = "" |
|
|
| return validate_browser_decision(normalized, allow_scroll=allow_scroll) |
|
|
|
|
| def validate_browser_decision(decision: dict[str, Any], allow_scroll: bool = True) -> dict[str, Any]: |
| """Validate and normalize a browser agent decision.""" |
| action = str(decision.get("action", "")).strip().upper() |
| value = str(decision.get("value", "")).strip() |
| answer = str(decision.get("answer", "")).strip() |
|
|
| normalized = { |
| "action": action or "DONE", |
| "value": value, |
| "answer": answer, |
| "reason": str(decision.get("reason", "")).strip(), |
| "known_facts": _normalize_points(decision.get("known_facts")), |
| "missing_points": _normalize_points(decision.get("missing_points")), |
| } |
|
|
| if normalized["action"] == "SEARCH": |
| if not normalized["value"]: |
| raise ValueError("SEARCH decision requires a non-empty query") |
| elif normalized["action"] == "NAVIGATE": |
| if not normalized["value"].startswith("http"): |
| raise ValueError("NAVIGATE decision requires an absolute URL") |
| elif normalized["action"] == "SCROLL": |
| if not allow_scroll: |
| raise ValueError("SCROLL is not supported in this browser mode") |
| normalized["value"] = "" |
| elif normalized["action"] == "DONE": |
| pass |
| else: |
| raise ValueError(f"Unsupported browser action '{normalized['action']}'") |
|
|
| return normalized |
|
|
|
|
| def _extract_json_object(text: str) -> str: |
| """Extract the first JSON object-looking slice from a model response.""" |
| raw = (text or "").strip() |
| if raw.startswith("```"): |
| parts = raw.split("```") |
| if len(parts) >= 2: |
| raw = parts[1] |
| if raw.startswith("json"): |
| raw = raw[4:] |
| raw = raw.strip() |
|
|
| start = raw.find("{") |
| end = raw.rfind("}") |
| if start == -1 or end == -1 or end <= start: |
| raise ValueError("Model response did not contain a JSON object") |
| return raw[start:end + 1] |
|
|