mirror of
https://github.com/browser-use/browser-use.git
synced 2026-09-14 19:59:47 +08:00
1866 lines
64 KiB
Python
1866 lines
64 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import mimetypes
|
|
import re
|
|
import shutil
|
|
import urllib.parse
|
|
from collections.abc import Awaitable, Callable
|
|
from pathlib import Path
|
|
from typing import Any, Literal, cast
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
from uuid_extensions import uuid7str
|
|
|
|
from browser_use.agent.runtime.context import ToolResultItem
|
|
from browser_use.agent.runtime.tool_inputs import (
|
|
AccessibilityTreeInput,
|
|
CdpCommandInput,
|
|
ClickCoordinatesInput,
|
|
GetStateInput,
|
|
HtmlInput,
|
|
HttpFetchInput,
|
|
InspectElementInput,
|
|
MarkdownInput,
|
|
NetworkStateInput,
|
|
ShellRunInput,
|
|
WorkspaceImportArtifactsInput,
|
|
WorkspaceListFilesInput,
|
|
WorkspaceReadFileInput,
|
|
WorkspaceWriteFileInput,
|
|
)
|
|
from browser_use.agent.runtime.views import BrowserRuntimeEventTypes, ToolContext
|
|
from browser_use.agent.views import ActionResult
|
|
from browser_use.browser.services import BrowserServiceBundle
|
|
from browser_use.browser.views import BrowserError
|
|
from browser_use.tools.dropdown import dropdown_options_action, select_dropdown_action
|
|
from browser_use.tools.element_actions import (
|
|
click_by_coordinate_action,
|
|
click_by_index_action,
|
|
find_text_action,
|
|
input_text_action,
|
|
)
|
|
from browser_use.tools.error_handling import handle_browser_error
|
|
from browser_use.tools.evaluate import execute_evaluate_action
|
|
from browser_use.tools.execution import _coerce_valid_action_timeout
|
|
from browser_use.tools.extraction.action import extract_action
|
|
from browser_use.tools.file_actions import (
|
|
read_file_action,
|
|
replace_file_action,
|
|
save_as_pdf_action,
|
|
take_screenshot_action,
|
|
write_file_action,
|
|
)
|
|
from browser_use.tools.navigation import close_tab_action, send_keys_action, switch_tab_action
|
|
from browser_use.tools.page_query import find_elements_action, search_page_action
|
|
from browser_use.tools.registry.views import RegisteredAction
|
|
from browser_use.tools.service import Tools
|
|
from browser_use.tools.upload import upload_file_action
|
|
from browser_use.tools.views import (
|
|
ClickElementAction,
|
|
ClickElementActionIndexOnly,
|
|
CloseTabAction,
|
|
DoneAction,
|
|
EvaluateAction,
|
|
ExtractAction,
|
|
FindElementsAction,
|
|
FindTextAction,
|
|
GetDropdownOptionsAction,
|
|
InputTextAction,
|
|
NavigateAction,
|
|
ReadFileAction,
|
|
ReplaceFileAction,
|
|
SaveAsPdfAction,
|
|
ScreenshotAction,
|
|
ScrollAction,
|
|
SearchAction,
|
|
SearchPageAction,
|
|
SelectDropdownOptionAction,
|
|
SendKeysAction,
|
|
StructuredDoneInput,
|
|
SwitchTabAction,
|
|
UploadFileAction,
|
|
WriteFileAction,
|
|
)
|
|
|
|
|
|
def _api_safe_tool_name(name: str) -> str:
|
|
"""Convert canonical dotted tool names to provider-safe function names."""
|
|
return re.sub(r'[^a-zA-Z0-9_-]', '_', name)
|
|
|
|
|
|
class NativeToolDefinition(BaseModel):
|
|
"""Native tool definition backed by a Pydantic input model."""
|
|
|
|
model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True)
|
|
|
|
name: str
|
|
description: str
|
|
input_model: type[BaseModel] = Field(exclude=True)
|
|
api_name: str | None = None
|
|
source_action: str | None = None
|
|
terminates_sequence: bool = False
|
|
executable: bool = True
|
|
|
|
@model_validator(mode='after')
|
|
def _set_api_name(self) -> NativeToolDefinition:
|
|
if self.api_name is None:
|
|
self.api_name = _api_safe_tool_name(self.name)
|
|
return self
|
|
|
|
@property
|
|
def input_schema(self) -> dict[str, Any]:
|
|
return self.input_model.model_json_schema()
|
|
|
|
def as_function_tool(self) -> dict[str, Any]:
|
|
"""Return an OpenAI-compatible function tool schema."""
|
|
return {
|
|
'type': 'function',
|
|
'function': {
|
|
'name': self.api_name,
|
|
'description': self.description,
|
|
'parameters': self.input_schema,
|
|
},
|
|
}
|
|
|
|
@classmethod
|
|
def from_registered_action(cls, *, native_name: str, action: RegisteredAction) -> NativeToolDefinition:
|
|
return cls(
|
|
name=native_name,
|
|
description=action.description,
|
|
input_model=action.param_model,
|
|
source_action=action.name,
|
|
terminates_sequence=action.terminates_sequence,
|
|
executable=True,
|
|
)
|
|
|
|
@classmethod
|
|
def from_structured_done_model(cls, *, output_model: type[BaseModel], source_action: str = 'done') -> NativeToolDefinition:
|
|
return cls(
|
|
name='browser.done',
|
|
description='Complete task with structured output.',
|
|
input_model=StructuredDoneInput[output_model],
|
|
source_action=source_action,
|
|
terminates_sequence=True,
|
|
executable=True,
|
|
)
|
|
|
|
|
|
class NativeToolCall(BaseModel):
|
|
"""A model-requested native tool call."""
|
|
|
|
tool_name: str
|
|
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
call_id: str = Field(default_factory=uuid7str)
|
|
|
|
|
|
class NativeToolResult(BaseModel):
|
|
"""Structured result returned from a native tool call."""
|
|
|
|
tool_name: str
|
|
call_id: str
|
|
is_error: bool = False
|
|
content: str | None = None
|
|
structured_content: dict[str, Any] = Field(default_factory=dict)
|
|
artifact_ids: list[str] = Field(default_factory=list)
|
|
action_result: ActionResult | None = Field(default=None, exclude=True)
|
|
|
|
@classmethod
|
|
def from_action_result(cls, *, call: NativeToolCall, result: ActionResult) -> NativeToolResult:
|
|
content = result.error or (
|
|
result.extracted_content if result.is_done else result.long_term_memory or result.extracted_content
|
|
)
|
|
return cls(
|
|
tool_name=call.tool_name,
|
|
call_id=call.call_id,
|
|
is_error=bool(result.error),
|
|
content=content,
|
|
structured_content=result.model_dump(exclude_none=True),
|
|
action_result=result,
|
|
)
|
|
|
|
def to_context_item(self) -> ToolResultItem:
|
|
return ToolResultItem(
|
|
tool_name=self.tool_name,
|
|
call_id=self.call_id,
|
|
content=None if self.is_error else self.content,
|
|
error=self.content if self.is_error else None,
|
|
structured_content=self.structured_content,
|
|
artifact_ids=self.artifact_ids,
|
|
)
|
|
|
|
|
|
_ACTION_TO_NATIVE_TOOL = {
|
|
'search': 'browser.search',
|
|
'navigate': 'browser.navigate',
|
|
'go_back': 'browser.go_back',
|
|
'wait': 'browser.wait',
|
|
'click': 'browser.click',
|
|
'input': 'browser.type',
|
|
'upload_file': 'browser.upload_file',
|
|
'switch': 'browser.switch_tab',
|
|
'close': 'browser.close_tab',
|
|
'extract': 'browser.extract',
|
|
'search_page': 'browser.search_page',
|
|
'find_elements': 'browser.find_elements',
|
|
'scroll': 'browser.scroll',
|
|
'send_keys': 'browser.send_keys',
|
|
'screenshot': 'browser.screenshot',
|
|
'save_as_pdf': 'browser.save_as_pdf',
|
|
'get_dropdown_options': 'browser.get_dropdown_options',
|
|
'select_dropdown_option': 'browser.select_dropdown_option',
|
|
'evaluate': 'browser.evaluate',
|
|
'done': 'browser.done',
|
|
}
|
|
|
|
_TOOL_CONTEXT_BUILTIN_ACTIONS = {
|
|
'click',
|
|
'input',
|
|
'upload_file',
|
|
'switch',
|
|
'close',
|
|
'extract',
|
|
'search_page',
|
|
'find_elements',
|
|
'send_keys',
|
|
'find_text',
|
|
'screenshot',
|
|
'save_as_pdf',
|
|
'dropdown_options',
|
|
'select_dropdown',
|
|
'write_file',
|
|
'replace_file',
|
|
'read_file',
|
|
'evaluate',
|
|
}
|
|
|
|
|
|
def _experimental_definitions() -> list[NativeToolDefinition]:
|
|
return [
|
|
NativeToolDefinition(
|
|
name='browser.click_coordinates',
|
|
description='Click a viewport coordinate directly. Use when screenshot reasoning is more reliable than a DOM index.',
|
|
input_model=ClickCoordinatesInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='browser.cdp',
|
|
description='Send a raw Chrome DevTools Protocol command using full runtime CDP handles.',
|
|
input_model=CdpCommandInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='browser.get_state',
|
|
description='Request a fresh browser state snapshot.',
|
|
input_model=GetStateInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='browser.html',
|
|
description='Read raw HTML for the full page or a CSS selector. Use when cleaned DOM/markdown omits needed structure.',
|
|
input_model=HtmlInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='browser.get_html',
|
|
description='Read raw HTML for the full page or a CSS selector. Alias for browser.html with a clearer escape-hatch name.',
|
|
input_model=HtmlInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='browser.markdown',
|
|
description='Read the current page as cleaned markdown. Use for dense text extraction before reaching for raw HTML.',
|
|
input_model=MarkdownInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='browser.accessibility',
|
|
description='Read the accessibility tree. Use for roles/names when DOM indexes or screenshots are ambiguous.',
|
|
input_model=AccessibilityTreeInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='browser.get_accessibility_tree',
|
|
description='Read the accessibility tree. Alias for browser.accessibility with a clearer escape-hatch name.',
|
|
input_model=AccessibilityTreeInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='browser.inspect_element',
|
|
description='Inspect one element by Browser Use index/backendNodeId, CSS selector, or raw backendNodeId.',
|
|
input_model=InspectElementInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='browser.network',
|
|
description='Inspect pending and recent network requests for the current page.',
|
|
input_model=NetworkStateInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='browser.http_fetch',
|
|
description='Fetch a URL from inside the browser context with page credentials. Use for APIs or page-adjacent data.',
|
|
input_model=HttpFetchInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='browser.fetch',
|
|
description='Fetch a URL from inside the browser context with page credentials. Alias for browser.http_fetch.',
|
|
input_model=HttpFetchInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='file.read',
|
|
description='Read a text file from the agent workspace.',
|
|
input_model=WorkspaceReadFileInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='file.write',
|
|
description='Write or append a text file inside the agent workspace.',
|
|
input_model=WorkspaceWriteFileInput,
|
|
executable=True,
|
|
),
|
|
]
|
|
|
|
|
|
def _workspace_definitions() -> list[NativeToolDefinition]:
|
|
return [
|
|
NativeToolDefinition(
|
|
name='workspace.read_file',
|
|
description='Read a text file from the configured workspace root. Requires allow_file_tools metadata.',
|
|
input_model=WorkspaceReadFileInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='workspace.write_file',
|
|
description='Write or append a text file inside the configured workspace root. Requires allow_file_tools metadata.',
|
|
input_model=WorkspaceWriteFileInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='workspace.list_files',
|
|
description='List files inside the configured workspace root. Requires allow_file_tools metadata.',
|
|
input_model=WorkspaceListFilesInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='workspace.import_artifacts',
|
|
description='Copy available_file_paths, browser downloads, artifact-store paths, or explicit file paths into the workspace.',
|
|
input_model=WorkspaceImportArtifactsInput,
|
|
executable=True,
|
|
),
|
|
NativeToolDefinition(
|
|
name='shell.run',
|
|
description='Run an argv-style command in the configured workspace root. Requires allow_shell_tools metadata.',
|
|
input_model=ShellRunInput,
|
|
executable=True,
|
|
),
|
|
]
|
|
|
|
|
|
class NativeToolRouter(BaseModel):
|
|
"""Routes provider-native tool calls through explicit runtime context."""
|
|
|
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
|
|
definitions: dict[str, NativeToolDefinition] = Field(default_factory=dict)
|
|
|
|
@classmethod
|
|
def from_tools(
|
|
cls,
|
|
tools: Tools,
|
|
*,
|
|
include_actions: list[str] | None = None,
|
|
page_url: str | None = None,
|
|
include_experimental: bool = True,
|
|
include_workspace_tools: bool = False,
|
|
) -> NativeToolRouter:
|
|
definitions: dict[str, NativeToolDefinition] = {}
|
|
|
|
for action_name, action in tools.registry.registry.actions.items():
|
|
if include_actions is not None and action_name not in include_actions:
|
|
continue
|
|
if page_url is None:
|
|
if action.domains is not None:
|
|
continue
|
|
elif not tools.registry.registry._match_domains(action.domains, page_url):
|
|
continue
|
|
|
|
native_name = _ACTION_TO_NATIVE_TOOL.get(action_name, f'browser.{action_name}')
|
|
if action_name == 'done' and (output_model := tools.get_output_model()) is not None:
|
|
definition = NativeToolDefinition.from_structured_done_model(output_model=output_model)
|
|
else:
|
|
definition = NativeToolDefinition.from_registered_action(native_name=native_name, action=action)
|
|
definitions[definition.name] = definition
|
|
|
|
if include_experimental:
|
|
for definition in _experimental_definitions():
|
|
definitions.setdefault(definition.name, definition)
|
|
|
|
if include_workspace_tools:
|
|
for definition in _workspace_definitions():
|
|
definitions.setdefault(definition.name, definition)
|
|
|
|
return cls(definitions=definitions)
|
|
|
|
def resolve(self, tool_name: str) -> NativeToolDefinition:
|
|
if tool_name in self.definitions:
|
|
return self.definitions[tool_name]
|
|
|
|
for definition in self.definitions.values():
|
|
if definition.api_name == tool_name:
|
|
return definition
|
|
|
|
raise KeyError(f'Native tool {tool_name} is not registered')
|
|
|
|
def tool_schemas(self) -> list[dict[str, Any]]:
|
|
return [definition.as_function_tool() for definition in self.definitions.values()]
|
|
|
|
def guidance(self) -> str:
|
|
return (
|
|
'Prefer browser.click/type/scroll on DOM indexes for ordinary interaction. '
|
|
'Use browser.get_state to refresh DOM, screenshot, targetId, and sessionId handles. '
|
|
'Use browser.click_coordinates when visual placement is clearer than a DOM index. '
|
|
'Use browser.markdown for readable page text, browser.get_html for raw structure, and browser.inspect_element for one element. '
|
|
'Use browser.get_accessibility_tree for roles/names, browser.network for request debugging, browser.fetch for API/data reads, '
|
|
'browser.cdp only when lower-level CDP handles or browser primitives are needed, and file.read/file.write for workspace files.'
|
|
)
|
|
|
|
def validate_call(self, call: NativeToolCall) -> BaseModel:
|
|
definition = self.resolve(call.tool_name)
|
|
try:
|
|
return definition.input_model.model_validate(call.arguments)
|
|
except ValidationError as e:
|
|
raise ValueError(f'Invalid arguments for native tool {call.tool_name}: {e}') from e
|
|
|
|
async def execute(self, call: NativeToolCall, context: ToolContext) -> NativeToolResult:
|
|
definition = self.resolve(call.tool_name)
|
|
if definition.name == 'browser.search':
|
|
params = cast(SearchAction, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._search(params, context))
|
|
|
|
if definition.name == 'browser.navigate':
|
|
params = cast(NavigateAction, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._navigate(params, context))
|
|
|
|
if definition.name == 'browser.go_back':
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._go_back(context))
|
|
|
|
if definition.name == 'browser.wait':
|
|
params = self.validate_call(call)
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._wait(params))
|
|
|
|
if definition.name == 'browser.done':
|
|
params = cast(DoneAction | StructuredDoneInput[Any], self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._done(call, params, context))
|
|
|
|
if definition.name == 'browser.scroll':
|
|
params = cast(ScrollAction, self.validate_call(call))
|
|
if params.index not in (None, 0):
|
|
return await self._execute_registered_action(call, context, definition)
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._scroll(params, context))
|
|
|
|
if definition.name == 'browser.click_coordinates':
|
|
params = cast(ClickCoordinatesInput, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._click_coordinates(params, context))
|
|
|
|
if definition.name == 'browser.cdp':
|
|
params = cast(CdpCommandInput, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._send_cdp(params, context))
|
|
|
|
if definition.name == 'browser.get_state':
|
|
params = cast(GetStateInput, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._get_state(params, context))
|
|
|
|
if definition.name in ('browser.html', 'browser.get_html'):
|
|
params = cast(HtmlInput, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._get_html(params, context))
|
|
|
|
if definition.name == 'browser.markdown':
|
|
params = cast(MarkdownInput, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._get_markdown(params, context))
|
|
|
|
if definition.name in ('browser.accessibility', 'browser.get_accessibility_tree'):
|
|
params = cast(AccessibilityTreeInput, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._get_accessibility(params, context))
|
|
|
|
if definition.name == 'browser.inspect_element':
|
|
params = cast(InspectElementInput, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._inspect_element(params, context))
|
|
|
|
if definition.name == 'browser.network':
|
|
params = cast(NetworkStateInput, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._get_network(params, context))
|
|
|
|
if definition.name in ('browser.http_fetch', 'browser.fetch'):
|
|
params = cast(HttpFetchInput, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._http_fetch(params, context))
|
|
|
|
if definition.name == 'file.read':
|
|
params = cast(WorkspaceReadFileInput, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._file_read(params, context))
|
|
|
|
if definition.name == 'file.write':
|
|
params = cast(WorkspaceWriteFileInput, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._file_write(params, context))
|
|
|
|
if definition.name == 'workspace.read_file':
|
|
params = cast(WorkspaceReadFileInput, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._workspace_read_file(params, context))
|
|
|
|
if definition.name == 'workspace.write_file':
|
|
params = cast(WorkspaceWriteFileInput, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._workspace_write_file(params, context))
|
|
|
|
if definition.name == 'workspace.list_files':
|
|
params = cast(WorkspaceListFilesInput, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._workspace_list_files(params, context))
|
|
|
|
if definition.name == 'workspace.import_artifacts':
|
|
params = cast(WorkspaceImportArtifactsInput, self.validate_call(call))
|
|
return await self._execute_direct_tool(
|
|
call, context, definition, lambda: self._workspace_import_artifacts(params, context)
|
|
)
|
|
|
|
if definition.name == 'shell.run':
|
|
params = cast(ShellRunInput, self.validate_call(call))
|
|
return await self._execute_direct_tool(call, context, definition, lambda: self._shell_run(params, context))
|
|
|
|
if definition.source_action in _TOOL_CONTEXT_BUILTIN_ACTIONS:
|
|
params = self.validate_call(call)
|
|
return await self._execute_direct_tool(
|
|
call,
|
|
context,
|
|
definition,
|
|
lambda: self._execute_builtin_action(definition.source_action or '', params, context, call),
|
|
)
|
|
|
|
return await self._execute_registered_action(call, context, definition)
|
|
|
|
async def _execute_builtin_action(
|
|
self,
|
|
action_name: str,
|
|
params: BaseModel,
|
|
context: ToolContext,
|
|
call: NativeToolCall,
|
|
) -> NativeToolResult:
|
|
"""Execute built-in Browser Use tools from explicit ToolContext instead of registry injection."""
|
|
browser_session = cast(Any, context.browser_session)
|
|
file_system = cast(Any, context.file_system)
|
|
page_extraction_llm = cast(Any, context.page_extraction_llm or context.llm)
|
|
|
|
if action_name == 'click':
|
|
click_params = cast(ClickElementAction | ClickElementActionIndexOnly, params)
|
|
if isinstance(click_params, ClickElementAction) and click_params.index is None:
|
|
result = await self._require_browser_action(
|
|
action_name,
|
|
context,
|
|
lambda: click_by_coordinate_action(click_params, browser_session),
|
|
)
|
|
else:
|
|
result = await self._require_browser_action(
|
|
action_name,
|
|
context,
|
|
lambda: click_by_index_action(click_params, browser_session),
|
|
)
|
|
elif action_name == 'input':
|
|
input_params = cast(InputTextAction, params)
|
|
result = await self._require_browser_action(
|
|
action_name,
|
|
context,
|
|
lambda: input_text_action(
|
|
input_params,
|
|
browser_session,
|
|
has_sensitive_data=bool(context.sensitive_data),
|
|
sensitive_data=context.sensitive_data,
|
|
),
|
|
)
|
|
elif action_name == 'upload_file':
|
|
result = await self._require_browser_and_files_action(
|
|
action_name,
|
|
context,
|
|
lambda: upload_file_action(
|
|
cast(UploadFileAction, params),
|
|
browser_session=browser_session,
|
|
available_file_paths=context.available_file_paths or [],
|
|
file_system=file_system,
|
|
),
|
|
)
|
|
elif action_name == 'switch':
|
|
result = await self._require_browser_action(
|
|
action_name, context, lambda: switch_tab_action(cast(SwitchTabAction, params), browser_session)
|
|
)
|
|
elif action_name == 'close':
|
|
result = await self._require_browser_action(
|
|
action_name, context, lambda: close_tab_action(cast(CloseTabAction, params), browser_session)
|
|
)
|
|
elif action_name == 'extract':
|
|
if context.page_extraction_llm is None and context.llm is None:
|
|
result = ActionResult(error='extract requires ToolContext.page_extraction_llm or ToolContext.llm.')
|
|
else:
|
|
result = await self._require_browser_and_files_action(
|
|
action_name,
|
|
context,
|
|
lambda: extract_action(
|
|
cast(ExtractAction, params),
|
|
browser_session=browser_session,
|
|
page_extraction_llm=page_extraction_llm,
|
|
file_system=file_system,
|
|
extraction_schema=context.extraction_schema,
|
|
),
|
|
)
|
|
elif action_name == 'search_page':
|
|
result = await self._require_browser_action(
|
|
action_name, context, lambda: search_page_action(cast(SearchPageAction, params), browser_session)
|
|
)
|
|
elif action_name == 'find_elements':
|
|
result = await self._require_browser_action(
|
|
action_name, context, lambda: find_elements_action(cast(FindElementsAction, params), browser_session)
|
|
)
|
|
elif action_name == 'send_keys':
|
|
result = await self._require_browser_action(
|
|
action_name, context, lambda: send_keys_action(cast(SendKeysAction, params), browser_session)
|
|
)
|
|
elif action_name == 'find_text':
|
|
result = await self._require_browser_action(
|
|
action_name, context, lambda: find_text_action(cast(FindTextAction, params), browser_session)
|
|
)
|
|
elif action_name == 'screenshot':
|
|
result = await self._require_browser_and_files_action(
|
|
action_name,
|
|
context,
|
|
lambda: take_screenshot_action(
|
|
cast(ScreenshotAction, params),
|
|
browser_session=browser_session,
|
|
file_system=file_system,
|
|
),
|
|
)
|
|
elif action_name == 'save_as_pdf':
|
|
result = await self._require_browser_and_files_action(
|
|
action_name,
|
|
context,
|
|
lambda: save_as_pdf_action(
|
|
cast(SaveAsPdfAction, params),
|
|
browser_session=browser_session,
|
|
file_system=file_system,
|
|
),
|
|
)
|
|
elif action_name == 'dropdown_options':
|
|
result = await self._require_browser_action(
|
|
action_name,
|
|
context,
|
|
lambda: dropdown_options_action(cast(GetDropdownOptionsAction, params), browser_session),
|
|
)
|
|
elif action_name == 'select_dropdown':
|
|
result = await self._require_browser_action(
|
|
action_name,
|
|
context,
|
|
lambda: select_dropdown_action(cast(SelectDropdownOptionAction, params), browser_session),
|
|
)
|
|
elif action_name == 'write_file':
|
|
result = await self._require_files_action(
|
|
action_name, context, lambda: write_file_action(cast(WriteFileAction, params), file_system)
|
|
)
|
|
elif action_name == 'replace_file':
|
|
result = await self._require_files_action(
|
|
action_name, context, lambda: replace_file_action(cast(ReplaceFileAction, params), file_system)
|
|
)
|
|
elif action_name == 'read_file':
|
|
result = await self._require_files_action(
|
|
action_name,
|
|
context,
|
|
lambda: read_file_action(
|
|
cast(ReadFileAction, params),
|
|
available_file_paths=context.available_file_paths or [],
|
|
file_system=file_system,
|
|
),
|
|
)
|
|
elif action_name == 'evaluate':
|
|
result = await self._require_browser_action(
|
|
action_name, context, lambda: execute_evaluate_action(cast(EvaluateAction, params), browser_session)
|
|
)
|
|
else:
|
|
return NativeToolResult(
|
|
tool_name=call.tool_name,
|
|
call_id=call.call_id,
|
|
is_error=True,
|
|
content=f'Built-in action {action_name} is not executable through ToolContext.',
|
|
)
|
|
|
|
return NativeToolResult.from_action_result(call=call, result=result)
|
|
|
|
async def _require_browser_action(
|
|
self,
|
|
action_name: str,
|
|
context: ToolContext,
|
|
operation: Callable[[], Awaitable[ActionResult]],
|
|
) -> ActionResult:
|
|
if context.browser_session is None:
|
|
return ActionResult(error=f'{action_name} requires ToolContext.browser_session.')
|
|
return await operation()
|
|
|
|
async def _require_files_action(
|
|
self,
|
|
action_name: str,
|
|
context: ToolContext,
|
|
operation: Callable[[], Awaitable[ActionResult]],
|
|
) -> ActionResult:
|
|
if context.file_system is None:
|
|
return ActionResult(error=f'{action_name} requires ToolContext.file_system.')
|
|
return await operation()
|
|
|
|
async def _require_browser_and_files_action(
|
|
self,
|
|
action_name: str,
|
|
context: ToolContext,
|
|
operation: Callable[[], Awaitable[ActionResult]],
|
|
) -> ActionResult:
|
|
if context.browser_session is None:
|
|
return ActionResult(error=f'{action_name} requires ToolContext.browser_session.')
|
|
if context.file_system is None:
|
|
return ActionResult(error=f'{action_name} requires ToolContext.file_system.')
|
|
return await operation()
|
|
|
|
async def _execute_registered_action(
|
|
self,
|
|
call: NativeToolCall,
|
|
context: ToolContext,
|
|
definition: NativeToolDefinition,
|
|
) -> NativeToolResult:
|
|
if not definition.executable or not definition.source_action:
|
|
return NativeToolResult(
|
|
tool_name=call.tool_name,
|
|
call_id=call.call_id,
|
|
is_error=True,
|
|
content=f'Native tool {definition.name} is defined but not executable in this runtime yet.',
|
|
)
|
|
|
|
if not isinstance(context.tools, Tools):
|
|
return NativeToolResult(
|
|
tool_name=call.tool_name,
|
|
call_id=call.call_id,
|
|
is_error=True,
|
|
content='Native tool execution requires ToolContext.tools to be a browser_use Tools instance.',
|
|
)
|
|
|
|
params = self.validate_call(call)
|
|
context.emit_tool_event(
|
|
BrowserRuntimeEventTypes.TOOL_STARTED,
|
|
{
|
|
'tool_name': definition.name,
|
|
'api_name': definition.api_name,
|
|
'source_action': definition.source_action,
|
|
},
|
|
)
|
|
|
|
timeout_s = _coerce_valid_action_timeout(context.action_timeout)
|
|
try:
|
|
raw_result = await asyncio.wait_for(
|
|
context.tools.registry.execute_action(
|
|
action_name=definition.source_action,
|
|
params=params.model_dump(),
|
|
browser_session=context.browser_session,
|
|
page_extraction_llm=context.page_extraction_llm or context.llm,
|
|
file_system=context.file_system,
|
|
sensitive_data=context.sensitive_data,
|
|
available_file_paths=context.available_file_paths,
|
|
extraction_schema=context.extraction_schema,
|
|
),
|
|
timeout=timeout_s,
|
|
)
|
|
except BrowserError as e:
|
|
result = handle_browser_error(e)
|
|
except TimeoutError:
|
|
result = ActionResult(error=f'Action {definition.source_action} timed out after {timeout_s:.0f}s.')
|
|
except Exception as e:
|
|
result = ActionResult(error=str(e))
|
|
else:
|
|
if isinstance(raw_result, ActionResult):
|
|
result = raw_result
|
|
elif isinstance(raw_result, str):
|
|
result = ActionResult(extracted_content=raw_result)
|
|
elif raw_result is None:
|
|
result = ActionResult()
|
|
else:
|
|
result = ActionResult(extracted_content=str(raw_result))
|
|
|
|
native_result = NativeToolResult.from_action_result(call=call, result=result)
|
|
context.emit_tool_event(
|
|
BrowserRuntimeEventTypes.TOOL_COMPLETED if not native_result.is_error else BrowserRuntimeEventTypes.TOOL_FAILED,
|
|
{
|
|
'tool_name': definition.name,
|
|
'api_name': definition.api_name,
|
|
'source_action': definition.source_action,
|
|
'is_error': native_result.is_error,
|
|
},
|
|
)
|
|
return native_result
|
|
|
|
async def _execute_direct_tool(
|
|
self,
|
|
call: NativeToolCall,
|
|
context: ToolContext,
|
|
definition: NativeToolDefinition,
|
|
operation: Callable[[], Awaitable[NativeToolResult]],
|
|
) -> NativeToolResult:
|
|
context.emit_tool_event(
|
|
BrowserRuntimeEventTypes.TOOL_STARTED,
|
|
{
|
|
'tool_name': definition.name,
|
|
'api_name': definition.api_name,
|
|
'source_action': definition.source_action,
|
|
},
|
|
)
|
|
|
|
timeout_s = _coerce_valid_action_timeout(context.action_timeout)
|
|
try:
|
|
native_result = await asyncio.wait_for(operation(), timeout=timeout_s)
|
|
except BrowserError as e:
|
|
native_result = NativeToolResult.from_action_result(call=call, result=handle_browser_error(e))
|
|
except TimeoutError:
|
|
native_result = NativeToolResult(
|
|
tool_name=call.tool_name,
|
|
call_id=call.call_id,
|
|
is_error=True,
|
|
content=f'Native tool {definition.name} timed out after {timeout_s:.0f}s.',
|
|
)
|
|
except Exception as e:
|
|
native_result = NativeToolResult(tool_name=call.tool_name, call_id=call.call_id, is_error=True, content=str(e))
|
|
|
|
native_result.tool_name = call.tool_name
|
|
native_result.call_id = call.call_id
|
|
context.emit_tool_event(
|
|
BrowserRuntimeEventTypes.TOOL_COMPLETED if not native_result.is_error else BrowserRuntimeEventTypes.TOOL_FAILED,
|
|
{
|
|
'tool_name': definition.name,
|
|
'api_name': definition.api_name,
|
|
'source_action': definition.source_action,
|
|
'is_error': native_result.is_error,
|
|
},
|
|
)
|
|
return native_result
|
|
|
|
async def _search(self, params: SearchAction, context: ToolContext) -> NativeToolResult:
|
|
if context.browser_session is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.search',
|
|
call_id='',
|
|
is_error=True,
|
|
content='browser.search requires ToolContext.browser_session.',
|
|
)
|
|
|
|
encoded_query = urllib.parse.quote_plus(params.query)
|
|
search_engines = {
|
|
'duckduckgo': f'https://duckduckgo.com/?q={encoded_query}',
|
|
'google': f'https://www.google.com/search?q={encoded_query}&udm=14',
|
|
'bing': f'https://www.bing.com/search?q={encoded_query}',
|
|
}
|
|
engine = params.engine.lower()
|
|
if engine not in search_engines:
|
|
return NativeToolResult(
|
|
tool_name='browser.search',
|
|
call_id='',
|
|
is_error=True,
|
|
content=f'Unsupported search engine: {params.engine}. Options: duckduckgo, google, bing',
|
|
)
|
|
|
|
url = search_engines[engine]
|
|
services = BrowserServiceBundle.from_session(context.browser_session)
|
|
await services.navigation.navigate(url, new_tab=False)
|
|
memory = f"Searched {engine.title()} for '{params.query}'"
|
|
return NativeToolResult(
|
|
tool_name='browser.search',
|
|
call_id='',
|
|
content=memory,
|
|
structured_content={'query': params.query, 'engine': engine, 'url': url, 'direct_service': True},
|
|
)
|
|
|
|
async def _navigate(self, params: NavigateAction, context: ToolContext) -> NativeToolResult:
|
|
if context.browser_session is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.navigate',
|
|
call_id='',
|
|
is_error=True,
|
|
content='browser.navigate requires ToolContext.browser_session.',
|
|
)
|
|
|
|
services = BrowserServiceBundle.from_session(context.browser_session)
|
|
await services.navigation.navigate(params.url, new_tab=params.new_tab)
|
|
content = f'Opened new tab with URL {params.url}' if params.new_tab else f'Navigated to {params.url}'
|
|
return NativeToolResult(
|
|
tool_name='browser.navigate',
|
|
call_id='',
|
|
content=content,
|
|
structured_content={'url': params.url, 'new_tab': params.new_tab, 'direct_service': True},
|
|
)
|
|
|
|
async def _go_back(self, context: ToolContext) -> NativeToolResult:
|
|
if context.browser_session is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.go_back',
|
|
call_id='',
|
|
is_error=True,
|
|
content='browser.go_back requires ToolContext.browser_session.',
|
|
)
|
|
|
|
services = BrowserServiceBundle.from_session(context.browser_session)
|
|
url = await services.navigation.go_back()
|
|
if url is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.go_back',
|
|
call_id='',
|
|
content='No previous history entry to go back to',
|
|
structured_content={'url': None, 'direct_service': True},
|
|
)
|
|
return NativeToolResult(
|
|
tool_name='browser.go_back',
|
|
call_id='',
|
|
content=f'Navigated back to {url}',
|
|
structured_content={'url': url, 'direct_service': True},
|
|
)
|
|
|
|
async def _wait(self, params: BaseModel) -> NativeToolResult:
|
|
seconds = int(getattr(params, 'seconds', 3))
|
|
actual_seconds = min(max(seconds - 1, 0), 30)
|
|
await asyncio.sleep(actual_seconds)
|
|
memory = f'Waited for {seconds} seconds'
|
|
return NativeToolResult(
|
|
tool_name='browser.wait',
|
|
call_id='',
|
|
content=memory,
|
|
structured_content={'seconds': seconds, 'actual_seconds': actual_seconds, 'direct_service': True},
|
|
)
|
|
|
|
async def _done(
|
|
self,
|
|
call: NativeToolCall,
|
|
params: DoneAction | StructuredDoneInput[Any],
|
|
context: ToolContext,
|
|
) -> NativeToolResult:
|
|
if not isinstance(context.tools, Tools):
|
|
return NativeToolResult(
|
|
tool_name=call.tool_name,
|
|
call_id=call.call_id,
|
|
is_error=True,
|
|
content='Native browser.done execution requires ToolContext.tools to be a browser_use Tools instance.',
|
|
)
|
|
|
|
if context.file_system is None:
|
|
return NativeToolResult(
|
|
tool_name=call.tool_name,
|
|
call_id=call.call_id,
|
|
is_error=True,
|
|
content='Native browser.done execution requires ToolContext.file_system.',
|
|
)
|
|
|
|
try:
|
|
result = context.tools.build_done_result(
|
|
params, file_system=context.file_system, browser_session=context.browser_session
|
|
)
|
|
except Exception as e:
|
|
return NativeToolResult(tool_name=call.tool_name, call_id=call.call_id, is_error=True, content=str(e))
|
|
|
|
return NativeToolResult.from_action_result(call=call, result=result)
|
|
|
|
async def _scroll(self, params: ScrollAction, context: ToolContext) -> NativeToolResult:
|
|
if context.browser_session is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.scroll',
|
|
call_id='',
|
|
is_error=True,
|
|
content='browser.scroll requires ToolContext.browser_session.',
|
|
)
|
|
|
|
services = BrowserServiceBundle.from_session(context.browser_session)
|
|
direction = 'down' if params.down else 'up'
|
|
metadata = await services.actions.scroll.scroll_pages(params.pages, direction=direction)
|
|
if params.pages == 1.0:
|
|
memory = f'Scrolled {direction} {metadata["viewport_height"]}px'
|
|
else:
|
|
memory = f'Scrolled {direction} {metadata["completed_pages"]:.1f} pages'
|
|
return NativeToolResult(
|
|
tool_name='browser.scroll',
|
|
call_id='',
|
|
content=memory,
|
|
structured_content={**metadata, 'index': params.index, 'direct_service': True},
|
|
)
|
|
|
|
async def _click_coordinates(self, params: ClickCoordinatesInput, context: ToolContext) -> NativeToolResult:
|
|
if context.browser_session is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.click_coordinates',
|
|
call_id='',
|
|
is_error=True,
|
|
content='browser.click_coordinates requires ToolContext.browser_session.',
|
|
)
|
|
|
|
services = BrowserServiceBundle.from_session(context.browser_session)
|
|
await services.actions.click.click_coordinates(params.coordinate_x, params.coordinate_y)
|
|
return NativeToolResult(
|
|
tool_name='browser.click_coordinates',
|
|
call_id='',
|
|
content=f'Clicked coordinates ({params.coordinate_x}, {params.coordinate_y})',
|
|
structured_content=click_coordinates_as_click_arguments(params),
|
|
)
|
|
|
|
async def _send_cdp(self, params: CdpCommandInput, context: ToolContext) -> NativeToolResult:
|
|
if context.browser_session is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.cdp',
|
|
call_id='',
|
|
is_error=True,
|
|
content='browser.cdp requires ToolContext.browser_session.',
|
|
)
|
|
|
|
target_id = cast(Any, params.target_id) if params.target_id is not None else None
|
|
cdp_session = await context.browser_session.get_or_create_cdp_session(
|
|
target_id=target_id,
|
|
focus=params.target_id is None,
|
|
)
|
|
session_id = params.session_id or cdp_session.session_id
|
|
response = await cdp_session.cdp_client.send_raw(params.method, params.params, session_id=session_id)
|
|
return NativeToolResult(
|
|
tool_name='browser.cdp',
|
|
call_id='',
|
|
content=f'CDP command {params.method} completed',
|
|
structured_content={
|
|
'method': params.method,
|
|
'params': params.params,
|
|
'target_id': params.target_id or str(cdp_session.target_id),
|
|
'session_id': session_id,
|
|
'response': response,
|
|
},
|
|
)
|
|
|
|
async def _get_state(self, params: GetStateInput, context: ToolContext) -> NativeToolResult:
|
|
if context.browser_session is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.get_state',
|
|
call_id='',
|
|
is_error=True,
|
|
content='browser.get_state requires ToolContext.browser_session.',
|
|
)
|
|
|
|
services = BrowserServiceBundle.from_session(context.browser_session)
|
|
state = await services.state.get_state(include_screenshot=params.include_screenshot)
|
|
cdp_session = await context.browser_session.get_or_create_cdp_session(target_id=None, focus=False)
|
|
context.emit_tool_event(
|
|
BrowserRuntimeEventTypes.BROWSER_STATE_REFRESHED,
|
|
{
|
|
'url': state.url,
|
|
'title': state.title,
|
|
'include_screenshot': params.include_screenshot,
|
|
'include_dom': params.include_dom,
|
|
},
|
|
)
|
|
|
|
tabs = [
|
|
{
|
|
'url': tab.url,
|
|
'title': tab.title,
|
|
'target_id': str(tab.target_id),
|
|
'parent_target_id': str(tab.parent_target_id) if tab.parent_target_id is not None else None,
|
|
}
|
|
for tab in state.tabs
|
|
]
|
|
structured_content: dict[str, Any] = {
|
|
'url': state.url,
|
|
'title': state.title,
|
|
'tabs': tabs,
|
|
'pixels_above': state.pixels_above,
|
|
'pixels_below': state.pixels_below,
|
|
'is_pdf_viewer': state.is_pdf_viewer,
|
|
'browser_errors': state.browser_errors,
|
|
'closed_popup_messages': state.closed_popup_messages,
|
|
'pending_network_requests': [
|
|
{
|
|
'url': request.url,
|
|
'method': request.method,
|
|
'resource_type': request.resource_type,
|
|
'loading_duration_ms': request.loading_duration_ms,
|
|
}
|
|
for request in state.pending_network_requests
|
|
],
|
|
'runtime_handles': {
|
|
'agent_focus_target_id': str(context.browser_session.agent_focus_target_id)
|
|
if context.browser_session.agent_focus_target_id is not None
|
|
else None,
|
|
'current_target_id': str(cdp_session.target_id),
|
|
'current_session_id': cdp_session.session_id,
|
|
'tab_target_ids': [tab['target_id'] for tab in tabs],
|
|
},
|
|
}
|
|
|
|
if params.include_dom:
|
|
structured_content['dom'] = state.dom_state.llm_representation()
|
|
structured_content['selector_count'] = len(state.dom_state.selector_map)
|
|
|
|
if params.include_screenshot:
|
|
structured_content['screenshot'] = state.screenshot
|
|
|
|
return NativeToolResult(
|
|
tool_name='browser.get_state',
|
|
call_id='',
|
|
content=f'Browser state captured for {state.url}',
|
|
structured_content=structured_content,
|
|
)
|
|
|
|
async def _get_html(self, params: HtmlInput, context: ToolContext) -> NativeToolResult:
|
|
if context.browser_session is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.html',
|
|
call_id='',
|
|
is_error=True,
|
|
content='browser.html requires ToolContext.browser_session.',
|
|
)
|
|
|
|
expression = (
|
|
f'(function(){{ const el = document.querySelector({json.dumps(params.selector)}); return el ? el.outerHTML : null; }})()'
|
|
if params.selector
|
|
else 'document.documentElement.outerHTML'
|
|
)
|
|
response = await self._runtime_evaluate(expression, context)
|
|
html = response.get('result', {}).get('value')
|
|
if html is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.html',
|
|
call_id='',
|
|
is_error=True,
|
|
content=f'No element found for selector: {params.selector}' if params.selector else 'Could not read page HTML.',
|
|
)
|
|
|
|
truncated = _truncate_text(str(html), params.max_chars)
|
|
return NativeToolResult(
|
|
tool_name='browser.html',
|
|
call_id='',
|
|
content=f'Read {truncated["returned_chars"]} HTML characters',
|
|
structured_content={'selector': params.selector, 'html': truncated['text'], **truncated},
|
|
)
|
|
|
|
async def _get_markdown(self, params: MarkdownInput, context: ToolContext) -> NativeToolResult:
|
|
if context.browser_session is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.markdown',
|
|
call_id='',
|
|
is_error=True,
|
|
content='browser.markdown requires ToolContext.browser_session.',
|
|
)
|
|
|
|
from browser_use.dom.markdown_extractor import extract_clean_markdown
|
|
|
|
markdown, stats = await extract_clean_markdown(
|
|
browser_session=context.browser_session,
|
|
extract_links=params.extract_links,
|
|
extract_images=params.extract_images,
|
|
)
|
|
truncated = _truncate_text(markdown, params.max_chars)
|
|
return NativeToolResult(
|
|
tool_name='browser.markdown',
|
|
call_id='',
|
|
content=f'Read {truncated["returned_chars"]} markdown characters',
|
|
structured_content={
|
|
'markdown': truncated['text'],
|
|
'stats': stats,
|
|
'extract_links': params.extract_links,
|
|
'extract_images': params.extract_images,
|
|
**truncated,
|
|
},
|
|
)
|
|
|
|
async def _get_accessibility(self, params: AccessibilityTreeInput, context: ToolContext) -> NativeToolResult:
|
|
if context.browser_session is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.accessibility',
|
|
call_id='',
|
|
is_error=True,
|
|
content='browser.accessibility requires ToolContext.browser_session.',
|
|
)
|
|
|
|
cdp_session = await context.browser_session.get_or_create_cdp_session(target_id=None, focus=False)
|
|
response = await cdp_session.cdp_client.send_raw(
|
|
'Accessibility.getFullAXTree',
|
|
{},
|
|
session_id=cdp_session.session_id,
|
|
)
|
|
nodes = response.get('nodes', [])
|
|
if not params.include_ignored:
|
|
nodes = [node for node in nodes if not node.get('ignored')]
|
|
return NativeToolResult(
|
|
tool_name='browser.accessibility',
|
|
call_id='',
|
|
content=f'Read {min(len(nodes), params.max_nodes)} accessibility nodes',
|
|
structured_content={
|
|
'nodes': nodes[: params.max_nodes],
|
|
'total_nodes': len(nodes),
|
|
'returned_nodes': min(len(nodes), params.max_nodes),
|
|
'truncated': len(nodes) > params.max_nodes,
|
|
'target_id': str(cdp_session.target_id),
|
|
'session_id': cdp_session.session_id,
|
|
},
|
|
)
|
|
|
|
async def _inspect_element(self, params: InspectElementInput, context: ToolContext) -> NativeToolResult:
|
|
if context.browser_session is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.inspect_element',
|
|
call_id='',
|
|
is_error=True,
|
|
content='browser.inspect_element requires ToolContext.browser_session.',
|
|
)
|
|
|
|
if params.index is not None:
|
|
node = await context.browser_session.get_element_by_index(params.index)
|
|
if node is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.inspect_element',
|
|
call_id='',
|
|
is_error=True,
|
|
content=f'Element index {params.index} was not found.',
|
|
)
|
|
content: dict[str, Any] = {
|
|
'locator': {'index': params.index},
|
|
'tag_name': node.tag_name,
|
|
'backend_node_id': node.backend_node_id,
|
|
'node_id': node.node_id,
|
|
'attributes': node.attributes,
|
|
'text': node.get_all_children_text(max_depth=10),
|
|
'is_visible': node.is_visible,
|
|
'is_scrollable': node.is_scrollable,
|
|
'target_id': str(node.target_id),
|
|
'session_id': node.session_id,
|
|
'frame_id': node.frame_id,
|
|
'absolute_position': node.absolute_position.to_dict() if node.absolute_position else None,
|
|
}
|
|
if node.ax_node:
|
|
content['accessibility'] = {
|
|
'role': getattr(node.ax_node, 'role', None),
|
|
'name': getattr(node.ax_node, 'name', None),
|
|
'description': getattr(node.ax_node, 'description', None),
|
|
}
|
|
if params.include_html:
|
|
html_result = await self._html_for_backend_node(
|
|
node.backend_node_id, node.session_id, context, params.max_html_chars
|
|
)
|
|
content.update(html_result)
|
|
return NativeToolResult(
|
|
tool_name='browser.inspect_element',
|
|
call_id='',
|
|
content=f'Inspected element index {params.index}',
|
|
structured_content=content,
|
|
)
|
|
|
|
if params.selector is not None:
|
|
expression = f"""
|
|
(function() {{
|
|
const el = document.querySelector({json.dumps(params.selector)});
|
|
if (!el) return null;
|
|
const rect = el.getBoundingClientRect();
|
|
const attrs = Object.fromEntries(Array.from(el.attributes || []).map((attr) => [attr.name, attr.value]));
|
|
return {{
|
|
tag_name: el.tagName.toLowerCase(),
|
|
attributes: attrs,
|
|
text: el.innerText || el.textContent || '',
|
|
absolute_position: {{ x: rect.x, y: rect.y, width: rect.width, height: rect.height }},
|
|
html: {str(params.include_html).lower()} ? el.outerHTML : null,
|
|
}};
|
|
}})()
|
|
"""
|
|
response = await self._runtime_evaluate(expression, context)
|
|
value = response.get('result', {}).get('value')
|
|
if value is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.inspect_element',
|
|
call_id='',
|
|
is_error=True,
|
|
content=f'No element found for selector: {params.selector}',
|
|
)
|
|
if params.include_html and value.get('html') is not None:
|
|
value['html'] = _truncate_text(str(value['html']), params.max_html_chars)
|
|
return NativeToolResult(
|
|
tool_name='browser.inspect_element',
|
|
call_id='',
|
|
content=f'Inspected selector {params.selector}',
|
|
structured_content={'locator': {'selector': params.selector}, **value},
|
|
)
|
|
|
|
assert params.backend_node_id is not None
|
|
cdp_session = await context.browser_session.get_or_create_cdp_session(target_id=None, focus=False)
|
|
response = await cdp_session.cdp_client.send_raw(
|
|
'DOM.describeNode',
|
|
{'backendNodeId': params.backend_node_id, 'depth': 1, 'pierce': True},
|
|
session_id=cdp_session.session_id,
|
|
)
|
|
content = {
|
|
'locator': {'backend_node_id': params.backend_node_id},
|
|
'target_id': str(cdp_session.target_id),
|
|
'session_id': cdp_session.session_id,
|
|
'node': response.get('node'),
|
|
}
|
|
if params.include_html:
|
|
content.update(
|
|
await self._html_for_backend_node(params.backend_node_id, cdp_session.session_id, context, params.max_html_chars)
|
|
)
|
|
return NativeToolResult(
|
|
tool_name='browser.inspect_element',
|
|
call_id='',
|
|
content=f'Inspected backendNodeId {params.backend_node_id}',
|
|
structured_content=content,
|
|
)
|
|
|
|
async def _get_network(self, params: NetworkStateInput, context: ToolContext) -> NativeToolResult:
|
|
if context.browser_session is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.network',
|
|
call_id='',
|
|
is_error=True,
|
|
content='browser.network requires ToolContext.browser_session.',
|
|
)
|
|
|
|
state = await context.browser_session.get_browser_state_summary(include_screenshot=False)
|
|
pending = [
|
|
{
|
|
'url': request.url,
|
|
'method': request.method,
|
|
'resource_type': request.resource_type,
|
|
'loading_duration_ms': request.loading_duration_ms,
|
|
}
|
|
for request in state.pending_network_requests
|
|
]
|
|
performance_entries: list[dict[str, Any]] = []
|
|
if params.include_performance_entries:
|
|
expression = f"""
|
|
performance.getEntriesByType('resource').slice(-{params.max_entries}).map((entry) => ({{
|
|
name: entry.name,
|
|
initiatorType: entry.initiatorType,
|
|
startTime: Math.round(entry.startTime),
|
|
duration: Math.round(entry.duration),
|
|
transferSize: entry.transferSize || 0,
|
|
encodedBodySize: entry.encodedBodySize || 0,
|
|
decodedBodySize: entry.decodedBodySize || 0
|
|
}}))
|
|
"""
|
|
response = await self._runtime_evaluate(expression, context)
|
|
performance_entries = response.get('result', {}).get('value') or []
|
|
return NativeToolResult(
|
|
tool_name='browser.network',
|
|
call_id='',
|
|
content=f'Read {len(pending)} pending requests and {len(performance_entries)} recent entries',
|
|
structured_content={
|
|
'pending_requests': pending,
|
|
'performance_entries': performance_entries,
|
|
'url': state.url,
|
|
},
|
|
)
|
|
|
|
async def _http_fetch(self, params: HttpFetchInput, context: ToolContext) -> NativeToolResult:
|
|
if context.browser_session is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.http_fetch',
|
|
call_id='',
|
|
is_error=True,
|
|
content='browser.http_fetch requires ToolContext.browser_session.',
|
|
)
|
|
|
|
expression = f"""
|
|
(async () => {{
|
|
const response = await fetch({json.dumps(params.url)}, {{
|
|
method: {json.dumps(params.method.upper())},
|
|
headers: {json.dumps(params.headers)},
|
|
body: {json.dumps(params.body)},
|
|
credentials: {json.dumps(params.credentials)}
|
|
}});
|
|
const body = await response.text();
|
|
return {{
|
|
url: response.url,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
ok: response.ok,
|
|
headers: Object.fromEntries(response.headers.entries()),
|
|
body,
|
|
}};
|
|
}})()
|
|
"""
|
|
response = await self._runtime_evaluate(expression, context, await_promise=True)
|
|
value = response.get('result', {}).get('value')
|
|
if value is None:
|
|
return NativeToolResult(
|
|
tool_name='browser.http_fetch',
|
|
call_id='',
|
|
is_error=True,
|
|
content=f'Fetch failed for {params.url}',
|
|
structured_content={'cdp_response': response},
|
|
)
|
|
|
|
truncated = _truncate_text(str(value.get('body', '')), params.max_chars)
|
|
value['body'] = truncated['text']
|
|
value.update(truncated)
|
|
return NativeToolResult(
|
|
tool_name='browser.http_fetch',
|
|
call_id='',
|
|
is_error=not bool(value.get('ok')),
|
|
content=f'Fetched {value.get("status")} {value.get("url")}',
|
|
structured_content=value,
|
|
)
|
|
|
|
async def _runtime_evaluate(
|
|
self,
|
|
expression: str,
|
|
context: ToolContext,
|
|
*,
|
|
await_promise: bool = False,
|
|
) -> dict[str, Any]:
|
|
assert context.browser_session is not None
|
|
cdp_session = await context.browser_session.get_or_create_cdp_session(target_id=None, focus=False)
|
|
return await cdp_session.cdp_client.send_raw(
|
|
'Runtime.evaluate',
|
|
{'expression': expression, 'returnByValue': True, 'awaitPromise': await_promise},
|
|
session_id=cdp_session.session_id,
|
|
)
|
|
|
|
async def _html_for_backend_node(
|
|
self,
|
|
backend_node_id: int,
|
|
session_id: str | None,
|
|
context: ToolContext,
|
|
max_chars: int,
|
|
) -> dict[str, Any]:
|
|
assert context.browser_session is not None
|
|
cdp_session = await context.browser_session.get_or_create_cdp_session(target_id=None, focus=False)
|
|
resolved = await cdp_session.cdp_client.send_raw(
|
|
'DOM.resolveNode',
|
|
{'backendNodeId': backend_node_id},
|
|
session_id=session_id or cdp_session.session_id,
|
|
)
|
|
object_id = resolved.get('object', {}).get('objectId')
|
|
if object_id is None:
|
|
return {'html_error': f'Could not resolve backendNodeId {backend_node_id}'}
|
|
html_response = await cdp_session.cdp_client.send_raw(
|
|
'Runtime.callFunctionOn',
|
|
{
|
|
'objectId': object_id,
|
|
'functionDeclaration': 'function() { return this.outerHTML; }',
|
|
'returnByValue': True,
|
|
},
|
|
session_id=session_id or cdp_session.session_id,
|
|
)
|
|
html = html_response.get('result', {}).get('value')
|
|
if html is None:
|
|
return {'html_error': f'Could not read HTML for backendNodeId {backend_node_id}'}
|
|
return {'html': _truncate_text(str(html), max_chars)}
|
|
|
|
async def _file_read(self, params: WorkspaceReadFileInput, context: ToolContext) -> NativeToolResult:
|
|
try:
|
|
root = _agent_file_root(context)
|
|
path = _resolve_workspace_path(root, params.path)
|
|
except ValueError as e:
|
|
return NativeToolResult(tool_name='file.read', call_id='', is_error=True, content=str(e))
|
|
|
|
if not path.exists() or not path.is_file():
|
|
return NativeToolResult(
|
|
tool_name='file.read',
|
|
call_id='',
|
|
is_error=True,
|
|
content=f'File not found: {params.path}',
|
|
)
|
|
|
|
content = path.read_text(encoding='utf-8', errors='replace')
|
|
truncated = _truncate_text(content, params.max_chars)
|
|
artifact = context.artifact_store.add(
|
|
kind='workspace_file',
|
|
name=path.name,
|
|
path=path,
|
|
media_type=mimetypes.guess_type(path.name)[0],
|
|
metadata={'source': 'file.read', 'truncated': truncated['truncated']},
|
|
)
|
|
context.emit_tool_event(
|
|
BrowserRuntimeEventTypes.ARTIFACT_CREATED,
|
|
{'artifact_id': artifact.artifact_id, 'kind': artifact.kind, 'path': str(path)},
|
|
)
|
|
return NativeToolResult(
|
|
tool_name='file.read',
|
|
call_id='',
|
|
content=f'Read {truncated["returned_chars"]} characters from {params.path}',
|
|
artifact_ids=[artifact.artifact_id],
|
|
structured_content={
|
|
'path': str(path),
|
|
'content': truncated['text'],
|
|
'artifact_id': artifact.artifact_id,
|
|
**truncated,
|
|
},
|
|
)
|
|
|
|
async def _file_write(self, params: WorkspaceWriteFileInput, context: ToolContext) -> NativeToolResult:
|
|
try:
|
|
root = _agent_file_root(context)
|
|
path = _resolve_workspace_path(root, params.path)
|
|
except ValueError as e:
|
|
return NativeToolResult(tool_name='file.write', call_id='', is_error=True, content=str(e))
|
|
|
|
if not path.parent.exists():
|
|
if params.create_parent_dirs:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
else:
|
|
return NativeToolResult(
|
|
tool_name='file.write',
|
|
call_id='',
|
|
is_error=True,
|
|
content=f'Parent directory does not exist: {path.parent}',
|
|
)
|
|
|
|
mode = 'a' if params.append else 'w'
|
|
with path.open(mode, encoding='utf-8') as file:
|
|
file.write(params.content)
|
|
return NativeToolResult(
|
|
tool_name='file.write',
|
|
call_id='',
|
|
content=f'Wrote {len(params.content)} characters to {params.path}',
|
|
structured_content={
|
|
'path': str(path),
|
|
'bytes': path.stat().st_size,
|
|
'appended': params.append,
|
|
},
|
|
)
|
|
|
|
async def _workspace_read_file(self, params: WorkspaceReadFileInput, context: ToolContext) -> NativeToolResult:
|
|
try:
|
|
root = _workspace_root(context, permission='file')
|
|
path = _resolve_workspace_path(root, params.path)
|
|
except ValueError as e:
|
|
return NativeToolResult(tool_name='workspace.read_file', call_id='', is_error=True, content=str(e))
|
|
|
|
if not path.exists() or not path.is_file():
|
|
return NativeToolResult(
|
|
tool_name='workspace.read_file',
|
|
call_id='',
|
|
is_error=True,
|
|
content=f'File not found: {params.path}',
|
|
)
|
|
|
|
read_structured = cast(
|
|
Callable[..., Awaitable[dict[str, Any]]] | None, getattr(context.file_system, 'read_file_structured', None)
|
|
)
|
|
if callable(read_structured):
|
|
structured_result = await read_structured(str(path), external_file=True)
|
|
content = str(structured_result.get('message', ''))
|
|
else:
|
|
structured_result = None
|
|
content = path.read_text(encoding='utf-8', errors='replace')
|
|
truncated = _truncate_text(content, params.max_chars)
|
|
artifact = context.artifact_store.add(
|
|
kind='workspace_file',
|
|
name=path.name,
|
|
path=path,
|
|
media_type=mimetypes.guess_type(path.name)[0],
|
|
metadata={'source': 'workspace.read_file', 'truncated': truncated['truncated']},
|
|
)
|
|
context.emit_tool_event(
|
|
BrowserRuntimeEventTypes.ARTIFACT_CREATED,
|
|
{'artifact_id': artifact.artifact_id, 'kind': artifact.kind, 'path': str(path)},
|
|
)
|
|
return NativeToolResult(
|
|
tool_name='workspace.read_file',
|
|
call_id='',
|
|
content=f'Read {truncated["returned_chars"]} characters from {params.path}',
|
|
artifact_ids=[artifact.artifact_id],
|
|
structured_content={
|
|
'path': str(path),
|
|
'content': truncated['text'],
|
|
'images': structured_result.get('images') if structured_result else None,
|
|
'artifact_id': artifact.artifact_id,
|
|
**truncated,
|
|
},
|
|
)
|
|
|
|
async def _workspace_write_file(self, params: WorkspaceWriteFileInput, context: ToolContext) -> NativeToolResult:
|
|
try:
|
|
root = _workspace_root(context, permission='file')
|
|
path = _resolve_workspace_path(root, params.path)
|
|
except ValueError as e:
|
|
return NativeToolResult(tool_name='workspace.write_file', call_id='', is_error=True, content=str(e))
|
|
|
|
if not path.parent.exists():
|
|
if params.create_parent_dirs:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
else:
|
|
return NativeToolResult(
|
|
tool_name='workspace.write_file',
|
|
call_id='',
|
|
is_error=True,
|
|
content=f'Parent directory does not exist: {path.parent}',
|
|
)
|
|
|
|
mode = 'a' if params.append else 'w'
|
|
with path.open(mode, encoding='utf-8') as file:
|
|
file.write(params.content)
|
|
return NativeToolResult(
|
|
tool_name='workspace.write_file',
|
|
call_id='',
|
|
content=f'Wrote {len(params.content)} characters to {params.path}',
|
|
structured_content={
|
|
'path': str(path),
|
|
'bytes': path.stat().st_size,
|
|
'appended': params.append,
|
|
},
|
|
)
|
|
|
|
async def _workspace_list_files(self, params: WorkspaceListFilesInput, context: ToolContext) -> NativeToolResult:
|
|
try:
|
|
root = _workspace_root(context, permission='file')
|
|
path = _resolve_workspace_path(root, params.path)
|
|
except ValueError as e:
|
|
return NativeToolResult(tool_name='workspace.list_files', call_id='', is_error=True, content=str(e))
|
|
|
|
if not path.exists() or not path.is_dir():
|
|
return NativeToolResult(
|
|
tool_name='workspace.list_files',
|
|
call_id='',
|
|
is_error=True,
|
|
content=f'Directory not found: {params.path}',
|
|
)
|
|
|
|
iterator = path.rglob(params.pattern) if params.recursive else path.glob(params.pattern)
|
|
entries = []
|
|
for entry in iterator:
|
|
try:
|
|
resolved = entry.resolve()
|
|
if resolved == root or root not in resolved.parents:
|
|
continue
|
|
relative = resolved.relative_to(root)
|
|
entries.append(
|
|
{
|
|
'path': str(relative),
|
|
'type': 'directory' if resolved.is_dir() else 'file',
|
|
'bytes': resolved.stat().st_size if resolved.is_file() else None,
|
|
}
|
|
)
|
|
except OSError:
|
|
continue
|
|
if len(entries) >= params.max_entries:
|
|
break
|
|
|
|
return NativeToolResult(
|
|
tool_name='workspace.list_files',
|
|
call_id='',
|
|
content=f'Listed {len(entries)} workspace entries',
|
|
structured_content={
|
|
'root': str(root),
|
|
'path': str(path),
|
|
'entries': entries,
|
|
'max_entries': params.max_entries,
|
|
'truncated': len(entries) >= params.max_entries,
|
|
},
|
|
)
|
|
|
|
async def _workspace_import_artifacts(self, params: WorkspaceImportArtifactsInput, context: ToolContext) -> NativeToolResult:
|
|
try:
|
|
root = _workspace_root(context, permission='file')
|
|
destination_dir = _resolve_workspace_path(root, params.destination_dir)
|
|
except ValueError as e:
|
|
return NativeToolResult(tool_name='workspace.import_artifacts', call_id='', is_error=True, content=str(e))
|
|
|
|
destination_dir.mkdir(parents=True, exist_ok=True)
|
|
candidates = _workspace_artifact_candidates(params, context)
|
|
imported = []
|
|
skipped = []
|
|
seen_sources: set[Path] = set()
|
|
|
|
for source in candidates:
|
|
if len(imported) >= params.max_files:
|
|
break
|
|
try:
|
|
source_path = Path(source).expanduser().resolve()
|
|
if source_path in seen_sources:
|
|
continue
|
|
seen_sources.add(source_path)
|
|
if not source_path.exists() or not source_path.is_file():
|
|
skipped.append({'path': str(source), 'reason': 'not_found_or_not_file'})
|
|
continue
|
|
if source_path == root or root in source_path.parents:
|
|
skipped.append({'path': str(source_path), 'reason': 'already_in_workspace'})
|
|
continue
|
|
|
|
destination = _unique_destination(destination_dir, source_path.name, overwrite=params.overwrite)
|
|
shutil.copy2(source_path, destination)
|
|
artifact = context.artifact_store.add(
|
|
kind='workspace_import',
|
|
name=destination.name,
|
|
path=destination,
|
|
media_type=mimetypes.guess_type(destination.name)[0],
|
|
metadata={'source_path': str(source_path)},
|
|
)
|
|
context.emit_tool_event(
|
|
BrowserRuntimeEventTypes.ARTIFACT_CREATED,
|
|
{'artifact_id': artifact.artifact_id, 'kind': artifact.kind, 'path': str(destination)},
|
|
)
|
|
imported.append(
|
|
{
|
|
'source_path': str(source_path),
|
|
'workspace_path': str(destination.relative_to(root)),
|
|
'absolute_workspace_path': str(destination),
|
|
'artifact_id': artifact.artifact_id,
|
|
'bytes': destination.stat().st_size,
|
|
'media_type': artifact.media_type,
|
|
}
|
|
)
|
|
except OSError as exc:
|
|
skipped.append({'path': str(source), 'reason': str(exc)})
|
|
|
|
return NativeToolResult(
|
|
tool_name='workspace.import_artifacts',
|
|
call_id='',
|
|
content=f'Imported {len(imported)} artifact file(s) into the workspace',
|
|
artifact_ids=[item['artifact_id'] for item in imported],
|
|
structured_content={
|
|
'root': str(root),
|
|
'destination_dir': str(destination_dir.relative_to(root)),
|
|
'imported': imported,
|
|
'skipped': skipped,
|
|
'candidate_count': len(candidates),
|
|
'max_files': params.max_files,
|
|
'truncated': len(imported) >= params.max_files and len(candidates) > len(imported),
|
|
},
|
|
)
|
|
|
|
async def _shell_run(self, params: ShellRunInput, context: ToolContext) -> NativeToolResult:
|
|
try:
|
|
root = _workspace_root(context, permission='shell')
|
|
cwd = _resolve_workspace_path(root, params.cwd)
|
|
except ValueError as e:
|
|
return NativeToolResult(tool_name='shell.run', call_id='', is_error=True, content=str(e))
|
|
|
|
if not cwd.exists() or not cwd.is_dir():
|
|
return NativeToolResult(
|
|
tool_name='shell.run', call_id='', is_error=True, content=f'cwd is not a directory: {params.cwd}'
|
|
)
|
|
|
|
allowed_commands = context.metadata.get('allowed_shell_commands')
|
|
if allowed_commands is not None and params.command[0] not in set(allowed_commands):
|
|
return NativeToolResult(
|
|
tool_name='shell.run',
|
|
call_id='',
|
|
is_error=True,
|
|
content=f'Shell command is not allowed: {params.command[0]}',
|
|
)
|
|
|
|
process = await asyncio.create_subprocess_exec(
|
|
*params.command,
|
|
cwd=str(cwd),
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
try:
|
|
stdout_bytes, stderr_bytes = await asyncio.wait_for(process.communicate(), timeout=params.timeout_s)
|
|
timed_out = False
|
|
except TimeoutError:
|
|
process.kill()
|
|
stdout_bytes, stderr_bytes = await process.communicate()
|
|
timed_out = True
|
|
|
|
stdout = stdout_bytes.decode('utf-8', errors='replace')
|
|
stderr = stderr_bytes.decode('utf-8', errors='replace')
|
|
stdout_truncated = _truncate_text(stdout, params.max_output_chars)
|
|
stderr_truncated = _truncate_text(stderr, params.max_output_chars)
|
|
return NativeToolResult(
|
|
tool_name='shell.run',
|
|
call_id='',
|
|
is_error=timed_out or process.returncode != 0,
|
|
content=f'Shell command exited with code {process.returncode}',
|
|
structured_content={
|
|
'command': params.command,
|
|
'cwd': str(cwd),
|
|
'exit_code': process.returncode,
|
|
'timed_out': timed_out,
|
|
'stdout': stdout_truncated['text'],
|
|
'stderr': stderr_truncated['text'],
|
|
'stdout_original_chars': stdout_truncated['original_chars'],
|
|
'stderr_original_chars': stderr_truncated['original_chars'],
|
|
'stdout_truncated': stdout_truncated['truncated'],
|
|
'stderr_truncated': stderr_truncated['truncated'],
|
|
},
|
|
)
|
|
|
|
|
|
def click_coordinates_as_click_arguments(params: ClickCoordinatesInput) -> dict[str, Any]:
|
|
"""Translate coordinate-only input to the current click action shape."""
|
|
return ClickElementAction(
|
|
index=None,
|
|
coordinate_x=params.coordinate_x,
|
|
coordinate_y=params.coordinate_y,
|
|
).model_dump(exclude_none=True)
|
|
|
|
|
|
def _truncate_text(value: str, max_chars: int) -> dict[str, Any]:
|
|
truncated = len(value) > max_chars
|
|
text = value[:max_chars]
|
|
return {
|
|
'text': text,
|
|
'original_chars': len(value),
|
|
'returned_chars': len(text),
|
|
'truncated': truncated,
|
|
}
|
|
|
|
|
|
def _workspace_root(context: ToolContext, *, permission: Literal['file', 'shell']) -> Path:
|
|
permission_key = 'allow_file_tools' if permission == 'file' else 'allow_shell_tools'
|
|
if context.metadata.get(permission_key) is not True:
|
|
raise ValueError(f'{permission_key} metadata must be true to use {permission} workspace tools.')
|
|
|
|
root_value = context.metadata.get('workspace_root')
|
|
if root_value is None:
|
|
get_dir = getattr(context.file_system, 'get_dir', None)
|
|
if callable(get_dir):
|
|
root_value = get_dir()
|
|
|
|
if root_value is None:
|
|
raise ValueError('workspace_root metadata is required for workspace tools.')
|
|
|
|
root = Path(cast(str | Path, root_value)).expanduser().resolve()
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
return root
|
|
|
|
|
|
def _agent_file_root(context: ToolContext) -> Path:
|
|
get_dir = getattr(context.file_system, 'get_dir', None)
|
|
if not callable(get_dir):
|
|
raise ValueError('file tools require ToolContext.file_system.')
|
|
root = Path(cast(str | Path, get_dir())).expanduser().resolve()
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
return root
|
|
|
|
|
|
def _resolve_workspace_path(root: Path, path: str) -> Path:
|
|
requested = Path(path).expanduser()
|
|
candidate = requested.resolve() if requested.is_absolute() else (root / requested).resolve()
|
|
if candidate != root and root not in candidate.parents:
|
|
raise ValueError(f'Path escapes workspace root: {path}')
|
|
return candidate
|
|
|
|
|
|
def _workspace_artifact_candidates(params: WorkspaceImportArtifactsInput, context: ToolContext) -> list[str | Path]:
|
|
candidates: list[str | Path] = list(params.paths)
|
|
if params.include_available_file_paths and context.available_file_paths:
|
|
candidates.extend(context.available_file_paths)
|
|
if params.include_downloads and context.browser_session is not None:
|
|
candidates.extend(getattr(context.browser_session, 'downloaded_files', []) or [])
|
|
if params.include_artifacts:
|
|
candidates.extend(artifact.path for artifact in context.artifact_store.artifacts if artifact.path is not None)
|
|
return candidates
|
|
|
|
|
|
def _unique_destination(destination_dir: Path, file_name: str, *, overwrite: bool) -> Path:
|
|
clean_name = Path(file_name).name
|
|
destination = destination_dir / clean_name
|
|
if overwrite or not destination.exists():
|
|
return destination
|
|
|
|
stem = destination.stem
|
|
suffix = destination.suffix
|
|
counter = 1
|
|
while True:
|
|
candidate = destination_dir / f'{stem}-{counter}{suffix}'
|
|
if not candidate.exists():
|
|
return candidate
|
|
counter += 1
|