Files
2026-07-10 23:01:06 -07:00

650 lines
27 KiB
Python

import importlib.resources
from datetime import datetime
from typing import TYPE_CHECKING, Literal, Optional
from browser_use.browser.views import PLACEHOLDER_4PX_SCREENSHOT
from browser_use.dom.views import NodeType, SimplifiedNode
from browser_use.llm.messages import ContentPartImageParam, ContentPartTextParam, ImageURL, SystemMessage, UserMessage
from browser_use.observability import observe_debug
from browser_use.utils import is_new_tab_page, sanitize_surrogates
if TYPE_CHECKING:
from browser_use.agent.views import AgentStepInfo
from browser_use.browser.views import BrowserStateSummary
from browser_use.filesystem.file_system import FileSystem
def _is_anthropic_4_5_model(model_name: str | None) -> bool:
"""Check if the model is Claude Opus 4.5 or Haiku 4.5 (requires 4096+ token prompts for caching)."""
if not model_name:
return False
model_lower = model_name.lower()
# Check for Opus 4.5 or Haiku 4.5 variants
is_opus_4_5 = 'opus' in model_lower and ('4.5' in model_lower or '4-5' in model_lower)
is_haiku_4_5 = 'haiku' in model_lower and ('4.5' in model_lower or '4-5' in model_lower)
return is_opus_4_5 or is_haiku_4_5
class SystemPrompt:
_NATIVE_TOOL_OUTPUT_GUIDANCE = """<output>
Respond only by calling the provided `browser_use_step` function exactly once.
Pass the complete structured response in its `step` argument. Keep `thinking`, `evaluation_previous_goal`, `memory`, `next_goal`, and `action` as distinct fields inside `step`; `action` must be a non-empty array.
Do not emit assistant prose, Markdown, raw JSON, XML, `<invoke>`, or `<parameter>` syntax, and never place serialized fields inside a string field.
`current_plan_item` and `plan_update` are optional. See <planning> for details.
</output>"""
_PAGE_CHANGING_ACTIONS_WITH_EVALUATE = (
'- **Page-changing (always last):** `navigate`, `search`, `go_back`, `switch`, `evaluate` — these always change the page. '
'Remaining actions after them are skipped automatically. Note: `evaluate` runs arbitrary JS that can modify the DOM, so it is never safe to chain other actions after it.'
)
_PAGE_CHANGING_ACTIONS_WITH_CODE = (
'- **Page-changing / sequence-ending (always last):** `navigate`, `search`, `go_back`, `switch`, `evaluate`, `run_python` — these change the page or end the action sequence. '
'Remaining actions after them are skipped automatically. Note: `evaluate` and `run_python` can inspect or mutate the page, so it is never safe to chain other actions after them.'
)
_SHADOW_DOM_WITH_EVALUATE = '**Shadow DOM:** Elements inside shadow DOM that have `[index]` markers are directly clickable with `click(index)`. Do NOT use `evaluate` to click them.'
_SHADOW_DOM_WITH_CODE = (
'**Shadow DOM:** Elements inside shadow DOM that have `[index]` markers are directly clickable with `click(index)`. '
'Use evaluate or raw CDP only for shadow DOM elements that are NOT indexed.'
)
_CODE_MODE_PROMPT = '''<code_mode>
Choose the narrowest action that can complete the work:
1. Use normal browser actions for navigation, clicks, typing, scrolling, and indexed elements.
2. Use evaluate for page-local JavaScript: DOM queries, window/localStorage access, page-local fetch, bulk extraction, or unusual page interaction. Multiline JavaScript is transported directly; do not wrap page-only JavaScript in run_python.
3. Use run_python only when the workflow needs raw CDP, CDP events, multiple targets/sessions, workspace files, host-side HTTP, subprocesses, or substantial Python processing.
Never import or use Playwright. The browser is already controlled through browser actions, evaluate, and the injected CDP helpers.
Prefer one complete bounded extraction over repeated exploratory code cells. If browser_state, find, or query already provides the information, do not run code merely to inspect it again.
Inside run_python, use await cdp("Domain.method", params, session=None, request_timeout=30), await wait_for_event("Domain.event", timeout=30, session=None), await tabs(), and await targets(). Page/Runtime/DOM/Input/Network default to the focused tab; Browser/Target/SystemInfo default to root CDP. Use session="root", session="any", or a tab/target/session id for explicit routing.
Only when a larger Python workflow must consume page JavaScript, use await js(...). One-line JavaScript may use await js("document.title"). Multiline JavaScript must use a raw triple-quoted string, for example:
results = await js(r"""
() => [...document.querySelectorAll('a')].map(a => ({text: a.innerText, href: a.href}))
""")
Return compact output with print() or a final expression. Save large data to files and print the path plus a short summary.
Code runs inside the agent worker process with a fresh namespace for each cell. Relative paths passed to open() resolve inside the persistent workspace; with pathlib, use WORKSPACE_DIR / "file". Files and browser state persist but Python variables do not.
Cells already run in an async event loop and support top-level await. Use await directly; never call asyncio.run(), run_until_complete(), or create another event loop. Defining async def does not execute it: call it with top-level await, or write the workflow directly at top level.
Always set explicit timeouts on Python network and subprocess operations. Do not create background tasks, stop the event loop, change process-wide signal handlers, or mutate process globals. Avoid evaluating or returning entire framework internals, global objects, or other unbounded JavaScript structures; select only the fields needed.
Never print a large dataset or read a large artifact through read_file. Validate/filter it in Python, save it, and print only compact counts, sizes, samples, and the relative path. Use list_files, search_files, or bounded read_file when a later step needs to inspect it.
Before finishing bulk extraction, check semantic anomalies as well as record counts: duplicates, labels or headings mistaken for values, truncated fields, missing required fields, and inconsistent samples. Repair them rather than reporting only completeness statistics.
evaluate and run_python end the action sequence. After page-changing code, wait for the next browser_state before using element indexes.
Do not add prose outside the required structured action response.
</code_mode>'''
def __init__(
self,
max_actions_per_step: int = 3,
override_system_message: str | None = None,
extend_system_message: str | None = None,
use_thinking: bool = True,
flash_mode: bool = False,
is_anthropic: bool = False,
is_browser_use_model: bool = False,
model_name: str | None = None,
code_mode: bool = False,
):
self.max_actions_per_step = max_actions_per_step
self.use_thinking = use_thinking
self.flash_mode = flash_mode
self.is_anthropic = is_anthropic
self.is_browser_use_model = is_browser_use_model
self.model_name = model_name
# Check if this is an Anthropic 4.5 model that needs longer prompts for caching
self.is_anthropic_4_5 = _is_anthropic_4_5_model(model_name)
prompt = ''
if override_system_message is not None:
prompt = override_system_message
else:
self._load_prompt_template()
prompt = self.prompt_template.format(max_actions=self.max_actions_per_step)
if code_mode:
prompt = self._replace_legacy_evaluate_guidance(prompt)
if extend_system_message:
prompt += f'\n{extend_system_message}'
if code_mode:
prompt += f'\n{self._CODE_MODE_PROMPT}'
self.system_message = SystemMessage(content=prompt, cache=True)
@classmethod
def _replace_legacy_evaluate_guidance(cls, prompt: str) -> str:
"""Replace legacy action and textual-output guidance for native code mode."""
prompt = prompt.replace(cls._PAGE_CHANGING_ACTIONS_WITH_EVALUATE, cls._PAGE_CHANGING_ACTIONS_WITH_CODE).replace(
cls._SHADOW_DOM_WITH_EVALUATE,
cls._SHADOW_DOM_WITH_CODE,
)
start = prompt.find('<output>')
end = prompt.find('</output>', start)
if start != -1 and end != -1:
end += len('</output>')
prompt = f'{prompt[:start]}{cls._NATIVE_TOOL_OUTPUT_GUIDANCE}{prompt[end:]}'
return prompt
def _load_prompt_template(self) -> None:
"""Load the prompt template from the markdown file."""
try:
# Choose the appropriate template based on model type and mode
# Browser-use models use simplified prompts optimized for fine-tuned models
if self.is_browser_use_model:
if self.flash_mode:
template_filename = 'system_prompt_browser_use_flash.md'
elif self.use_thinking:
template_filename = 'system_prompt_browser_use.md'
else:
template_filename = 'system_prompt_browser_use_no_thinking.md'
# Anthropic 4.5 models (Opus 4.5, Haiku 4.5) need 4096+ token prompts for caching
elif self.is_anthropic_4_5 and self.flash_mode:
template_filename = 'system_prompt_anthropic_flash.md'
elif self.flash_mode and self.is_anthropic:
template_filename = 'system_prompt_flash_anthropic.md'
elif self.flash_mode:
template_filename = 'system_prompt_flash.md'
elif self.use_thinking:
template_filename = 'system_prompt.md'
else:
template_filename = 'system_prompt_no_thinking.md'
# This works both in development and when installed as a package
with (
importlib.resources.files('browser_use.agent.system_prompts')
.joinpath(template_filename)
.open('r', encoding='utf-8') as f
):
self.prompt_template = f.read()
except Exception as e:
raise RuntimeError(f'Failed to load system prompt template: {e}')
def get_system_message(self) -> SystemMessage:
"""
Get the system prompt for the agent.
Returns:
SystemMessage: Formatted system prompt
"""
return self.system_message
class AgentMessagePrompt:
vision_detail_level: Literal['auto', 'low', 'high']
def __init__(
self,
browser_state_summary: 'BrowserStateSummary',
file_system: 'FileSystem',
agent_history_description: str | None = None,
read_state_description: str | None = None,
task: str | None = None,
include_attributes: list[str] | None = None,
step_info: Optional['AgentStepInfo'] = None,
page_filtered_actions: str | None = None,
max_clickable_elements_length: int = 40000,
sensitive_data: str | None = None,
available_file_paths: list[str] | None = None,
screenshots: list[str] | None = None,
vision_detail_level: Literal['auto', 'low', 'high'] = 'auto',
include_recent_events: bool = False,
sample_images: list[ContentPartTextParam | ContentPartImageParam] | None = None,
read_state_images: list[dict] | None = None,
llm_screenshot_size: tuple[int, int] | None = None,
unavailable_skills_info: str | None = None,
plan_description: str | None = None,
):
self.browser_state: 'BrowserStateSummary' = browser_state_summary
self.file_system: 'FileSystem | None' = file_system
self.agent_history_description: str | None = agent_history_description
self.read_state_description: str | None = read_state_description
self.task: str | None = task
self.include_attributes = include_attributes
self.step_info = step_info
self.page_filtered_actions: str | None = page_filtered_actions
self.max_clickable_elements_length: int = max_clickable_elements_length
self.sensitive_data: str | None = sensitive_data
self.available_file_paths: list[str] | None = available_file_paths
self.screenshots = screenshots or []
self.vision_detail_level = vision_detail_level
self.include_recent_events = include_recent_events
self.sample_images = sample_images or []
self.read_state_images = read_state_images or []
self.unavailable_skills_info: str | None = unavailable_skills_info
self.plan_description: str | None = plan_description
self.llm_screenshot_size = llm_screenshot_size
assert self.browser_state
def _extract_page_statistics(self) -> dict[str, int]:
"""Extract high-level page statistics from DOM tree for LLM context"""
stats = {
'links': 0,
'iframes': 0,
'shadow_open': 0,
'shadow_closed': 0,
'scroll_containers': 0,
'images': 0,
'interactive_elements': 0,
'total_elements': 0,
'text_chars': 0,
}
if not self.browser_state.dom_state or not self.browser_state.dom_state._root:
return stats
def traverse_node(node: SimplifiedNode) -> None:
"""Recursively traverse simplified DOM tree to count elements"""
if not node or not node.original_node:
return
original = node.original_node
stats['total_elements'] += 1
# Count by node type and tag
if original.node_type == NodeType.ELEMENT_NODE:
tag = original.tag_name.lower() if original.tag_name else ''
if tag == 'a':
stats['links'] += 1
elif tag in ('iframe', 'frame'):
stats['iframes'] += 1
elif tag == 'img':
stats['images'] += 1
# Check if scrollable
if original.is_actually_scrollable:
stats['scroll_containers'] += 1
# Check if interactive
if node.is_interactive:
stats['interactive_elements'] += 1
# Check if this element hosts shadow DOM
if node.is_shadow_host:
# Check if any shadow children are closed
has_closed_shadow = any(
child.original_node.node_type == NodeType.DOCUMENT_FRAGMENT_NODE
and child.original_node.shadow_root_type
and child.original_node.shadow_root_type.lower() == 'closed'
for child in node.children
)
if has_closed_shadow:
stats['shadow_closed'] += 1
else:
stats['shadow_open'] += 1
elif original.node_type == NodeType.TEXT_NODE:
stats['text_chars'] += len(original.node_value.strip())
elif original.node_type == NodeType.DOCUMENT_FRAGMENT_NODE:
# Shadow DOM fragment - these are the actual shadow roots
# But don't double-count since we count them at the host level above
pass
# Traverse children
for child in node.children:
traverse_node(child)
traverse_node(self.browser_state.dom_state._root)
return stats
@observe_debug(ignore_input=True, ignore_output=True, name='_get_browser_state_description')
def _get_browser_state_description(self) -> str:
# Extract page statistics first
page_stats = self._extract_page_statistics()
# Format statistics
stats_text = '<page_stats>'
if page_stats['total_elements'] < 10:
stats_text += 'Page appears empty (SPA not loaded?) - '
# Skeleton screen: many elements but almost no text = loading placeholders
elif page_stats['total_elements'] > 20 and page_stats['text_chars'] < page_stats['total_elements'] * 5:
stats_text += 'Page appears to show skeleton/placeholder content (still loading?) - '
stats_text += f'{page_stats["links"]} links, {page_stats["interactive_elements"]} interactive, '
stats_text += f'{page_stats["iframes"]} iframes'
if page_stats['shadow_open'] > 0 or page_stats['shadow_closed'] > 0:
stats_text += f', {page_stats["shadow_open"]} shadow(open), {page_stats["shadow_closed"]} shadow(closed)'
if page_stats['images'] > 0:
stats_text += f', {page_stats["images"]} images'
stats_text += f', {page_stats["total_elements"]} total elements'
stats_text += '</page_stats>\n'
elements_text = self.browser_state.dom_state.llm_representation(include_attributes=self.include_attributes)
if len(elements_text) > self.max_clickable_elements_length:
elements_text = elements_text[: self.max_clickable_elements_length]
truncated_text = f' (truncated to {self.max_clickable_elements_length} characters)'
else:
truncated_text = ''
has_content_above = False
has_content_below = False
# Enhanced page information for the model
page_info_text = ''
if self.browser_state.page_info:
pi = self.browser_state.page_info
# Compute page statistics dynamically
pages_above = pi.pixels_above / pi.viewport_height if pi.viewport_height > 0 else 0
pages_below = pi.pixels_below / pi.viewport_height if pi.viewport_height > 0 else 0
has_content_above = pages_above > 0
has_content_below = pages_below > 0
page_info_text = '<page_info>'
page_info_text += f'{pages_above:.1f} pages above, {pages_below:.1f} pages below'
if pages_below > 0.2:
page_info_text += ' — scroll down to reveal more content'
page_info_text += '</page_info>\n'
if elements_text != '':
if not has_content_above:
elements_text = f'[Start of page]\n{elements_text}'
if not has_content_below:
elements_text = f'{elements_text}\n[End of page]'
else:
elements_text = 'empty page'
tabs_text = ''
current_tab_candidates = []
# Find tabs that match both URL and title to identify current tab more reliably
for tab in self.browser_state.tabs:
if tab.url == self.browser_state.url and tab.title == self.browser_state.title:
current_tab_candidates.append(tab.target_id)
# If we have exactly one match, mark it as current
# Otherwise, don't mark any tab as current to avoid confusion
current_target_id = current_tab_candidates[0] if len(current_tab_candidates) == 1 else None
for tab in self.browser_state.tabs:
tabs_text += f'Tab {tab.target_id[-4:]}: {tab.url} - {tab.title[:30]}\n'
current_tab_text = f'Current tab: {current_target_id[-4:]}' if current_target_id is not None else ''
# Check if current page is a PDF viewer and add appropriate message
pdf_message = ''
if self.browser_state.is_pdf_viewer:
pdf_message = (
'PDF viewer cannot be rendered. In this page, DO NOT use the extract action as PDF content cannot be rendered. '
)
pdf_message += (
'Use the read_file action on the downloaded PDF in available_file_paths to read the full text content.\n\n'
)
# Add recent events if available and requested
recent_events_text = ''
if self.include_recent_events and self.browser_state.recent_events:
recent_events_text = f'Recent browser events: {self.browser_state.recent_events}\n'
# Add closed popup messages if any
closed_popups_text = ''
if self.browser_state.closed_popup_messages:
closed_popups_text = 'Auto-closed JavaScript dialogs:\n'
for popup_msg in self.browser_state.closed_popup_messages:
closed_popups_text += f' - {popup_msg}\n'
closed_popups_text += '\n'
browser_state = f"""{stats_text}{current_tab_text}
Available tabs:
{tabs_text}
{page_info_text}
{recent_events_text}{closed_popups_text}{pdf_message}Interactive elements{truncated_text}:
{elements_text}
"""
return browser_state
def _get_agent_state_description(self) -> str:
_todo_contents = self.file_system.get_todo_contents() if self.file_system else ''
if not len(_todo_contents):
_todo_contents = '[empty todo.md, fill it when applicable]'
agent_state = f"""
<file_system>
{self.file_system.describe() if self.file_system else 'No file system available'}
</file_system>
<todo_contents>
{_todo_contents}
</todo_contents>
"""
if self.plan_description:
agent_state += f'<plan>\n{self.plan_description}\n</plan>\n'
if self.sensitive_data:
agent_state += f'<sensitive_data>{self.sensitive_data}</sensitive_data>\n'
if self.available_file_paths:
available_file_paths_text = '\n'.join(self.available_file_paths)
agent_state += f'<available_file_paths>{available_file_paths_text}\nUse with absolute paths</available_file_paths>\n'
return agent_state
def _get_user_request_description(self) -> str:
return f'<user_request>\n{self.task}\n</user_request>\n\n'
def _get_step_meta_description(self) -> str:
# Per-step varying metadata (step counter, wall-clock date). Kept out of <agent_state> so it
# lives at the tail of the user message — anything before this block can in principle be
# treated as the cacheable prefix.
if self.step_info:
step_info_description = f'Step{self.step_info.step_number + 1} maximum:{self.step_info.max_steps}\n'
else:
step_info_description = ''
step_info_description += f'Today:{datetime.now().strftime("%Y-%m-%d")}'
return f'<step_info>{step_info_description}</step_info>\n'
def _resize_screenshot(self, screenshot_b64: str) -> str:
"""Resize screenshot to llm_screenshot_size if configured."""
if not self.llm_screenshot_size:
return screenshot_b64
try:
import base64
import logging
from io import BytesIO
from PIL import Image
img = Image.open(BytesIO(base64.b64decode(screenshot_b64)))
if img.size == self.llm_screenshot_size:
return screenshot_b64
logging.getLogger(__name__).info(
f'🔄 Resizing screenshot from {img.size[0]}x{img.size[1]} to {self.llm_screenshot_size[0]}x{self.llm_screenshot_size[1]} for LLM'
)
img_resized = img.resize(self.llm_screenshot_size, Image.Resampling.LANCZOS)
buffer = BytesIO()
img_resized.save(buffer, format='PNG')
return base64.b64encode(buffer.getvalue()).decode('utf-8')
except Exception as e:
logging.getLogger(__name__).warning(f'Failed to resize screenshot: {e}, using original')
return screenshot_b64
@observe_debug(ignore_input=True, ignore_output=True, name='get_user_message')
def get_user_message(self, use_vision: bool = True) -> UserMessage:
"""Get complete state as a single cached message"""
# New-tab pages only carry placeholder screenshots, even later in a multi-tab session.
if is_new_tab_page(self.browser_state.url):
use_vision = False
# Build complete state description
state_description = (
self._get_user_request_description()
+ '<agent_history>\n'
+ (self.agent_history_description.strip('\n') if self.agent_history_description else '')
+ '\n</agent_history>\n\n'
)
state_description += '<agent_state>\n' + self._get_agent_state_description().strip('\n') + '\n</agent_state>\n'
state_description += '<browser_state>\n' + self._get_browser_state_description().strip('\n') + '\n</browser_state>\n'
# Only add read_state if it has content
read_state_description = self.read_state_description.strip('\n').strip() if self.read_state_description else ''
if read_state_description:
state_description += '<read_state>\n' + read_state_description + '\n</read_state>\n'
if self.page_filtered_actions:
state_description += '<page_specific_actions>\n'
state_description += self.page_filtered_actions + '\n'
state_description += '</page_specific_actions>\n'
# Add unavailable skills information if any
if self.unavailable_skills_info:
state_description += '\n' + self.unavailable_skills_info + '\n'
# Per-step varying metadata (step counter, date) lives at the tail of the message so that
# everything above can in principle be treated as a cacheable prefix.
state_description += self._get_step_meta_description()
# Sanitize surrogates from all text content
state_description = sanitize_surrogates(state_description)
# Check if we have images to include (from read_file action)
has_images = bool(self.read_state_images)
screenshots = [screenshot for screenshot in self.screenshots if screenshot != PLACEHOLDER_4PX_SCREENSHOT]
if (use_vision is True and screenshots) or has_images:
# Start with text description
content_parts: list[ContentPartTextParam | ContentPartImageParam] = [ContentPartTextParam(text=state_description)]
# Add sample images
content_parts.extend(self.sample_images)
# Add screenshots with labels
for i, screenshot in enumerate(screenshots):
if i == len(screenshots) - 1:
label = 'Current screenshot:'
else:
# Use simple, accurate labeling since we don't have actual step timing info
label = 'Previous screenshot:'
# Add label as text content
content_parts.append(ContentPartTextParam(text=label))
# Resize screenshot if llm_screenshot_size is configured
processed_screenshot = self._resize_screenshot(screenshot)
# Add the screenshot
content_parts.append(
ContentPartImageParam(
image_url=ImageURL(
url=f'data:image/png;base64,{processed_screenshot}',
media_type='image/png',
detail=self.vision_detail_level,
),
)
)
# Add read_state images (from read_file action) before screenshots
for img_data in self.read_state_images:
img_name = img_data.get('name', 'unknown')
img_base64 = img_data.get('data', '')
if not img_base64:
continue
# Detect image format from name
if img_name.lower().endswith('.png'):
media_type = 'image/png'
else:
media_type = 'image/jpeg'
# Add label
content_parts.append(ContentPartTextParam(text=f'Image from file: {img_name}'))
# Add the image
content_parts.append(
ContentPartImageParam(
image_url=ImageURL(
url=f'data:{media_type};base64,{img_base64}',
media_type=media_type,
detail=self.vision_detail_level,
),
)
)
return UserMessage(content=content_parts, cache=True)
return UserMessage(content=state_description, cache=True)
def get_rerun_summary_prompt(original_task: str, total_steps: int, success_count: int, error_count: int) -> str:
return f'''You are analyzing the completion of a rerun task. Based on the screenshot and execution info, provide a summary.
Original task: {original_task}
Execution statistics:
- Total steps: {total_steps}
- Successful steps: {success_count}
- Failed steps: {error_count}
Analyze the screenshot to determine:
1. Whether the task completed successfully
2. What the final state shows
3. Overall completion status (complete/partial/failed)
Respond with:
- summary: A clear, concise summary of what happened during the rerun
- success: Whether the task completed successfully (true/false)
- completion_status: One of "complete", "partial", or "failed"'''
def get_rerun_summary_message(prompt: str, screenshot_b64: str | None = None) -> UserMessage:
"""
Build a UserMessage for rerun summary generation.
Args:
prompt: The prompt text
screenshot_b64: Optional base64-encoded screenshot
Returns:
UserMessage with prompt and optional screenshot
"""
if screenshot_b64:
# With screenshot: use multi-part content
content_parts: list[ContentPartTextParam | ContentPartImageParam] = [
ContentPartTextParam(type='text', text=prompt),
ContentPartImageParam(
type='image_url',
image_url=ImageURL(url=f'data:image/png;base64,{screenshot_b64}'),
),
]
return UserMessage(content=content_parts)
else:
# Without screenshot: use simple string content
return UserMessage(content=prompt)
def get_ai_step_system_prompt() -> str:
"""
Get system prompt for AI step action used during rerun.
Returns:
System prompt string for AI step
"""
return """
You are an expert at extracting data from webpages.
<input>
You will be given:
1. A query describing what to extract
2. The markdown of the webpage (filtered to remove noise)
3. Optionally, a screenshot of the current page state
</input>
<instructions>
- Extract information from the webpage that is relevant to the query
- ONLY use the information available in the webpage - do not make up information
- If the information is not available, mention that clearly
- If the query asks for all items, list all of them
</instructions>
<output>
- Present ALL relevant information in a concise way
- Do not use conversational format - directly output the relevant information
- If information is unavailable, state that clearly
</output>
""".strip()
def get_ai_step_user_prompt(query: str, stats_summary: str, content: str) -> str:
"""
Build user prompt for AI step action.
Args:
query: What to extract or analyze
stats_summary: Content statistics summary
content: Page markdown content
Returns:
Formatted prompt string
"""
return f'<query>\n{query}\n</query>\n\n<content_stats>\n{stats_summary}\n</content_stats>\n\n<webpage_content>\n{content}\n</webpage_content>'