Lancer / app /agents /browser_decision.py
Madras1's picture
Upload 89 files
4bcc05b verified
Raw
History Blame Contribute Delete
6.81 kB
"""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": [],
}