Skip to content
69 changes: 69 additions & 0 deletions src/xagent/core/path_locks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Shared normalized-path mutation locks."""

from __future__ import annotations

import threading
from contextlib import ExitStack, contextmanager
from pathlib import Path
from typing import Iterator


class PathMutationLockRegistry:
"""Provide re-entrant locks keyed by normalized absolute filesystem path."""

def __init__(self) -> None:
self._guard = threading.Lock()
self._locks: dict[str, tuple[threading.RLock, int]] = {}

@staticmethod
def normalize_path(path: str | Path) -> Path:
return Path(path).expanduser().resolve()

def _acquire_lock_for_key(self, key: str) -> threading.RLock:
with self._guard:
lock_entry = self._locks.get(key)
if lock_entry is None:
lock = threading.RLock()
self._locks[key] = (lock, 1)
return lock

lock, ref_count = lock_entry
self._locks[key] = (lock, ref_count + 1)
return lock

def _release_lock_for_key(self, key: str) -> None:
with self._guard:
lock_entry = self._locks.get(key)
if lock_entry is None:
return

lock, ref_count = lock_entry
if ref_count <= 1:
self._locks.pop(key, None)
else:
self._locks[key] = (lock, ref_count - 1)

@contextmanager
def guard_path(self, path: str | Path) -> Iterator[Path]:
normalized_path = self.normalize_path(path)
key = str(normalized_path)
lock = self._acquire_lock_for_key(key)
try:
with lock:
yield normalized_path
finally:
self._release_lock_for_key(key)

@contextmanager
def guard_paths(self, paths: list[str | Path]) -> Iterator[tuple[Path, ...]]:
normalized_paths = tuple(self.normalize_path(path) for path in paths)
unique_paths = {str(path): path for path in normalized_paths}
ordered_paths = [unique_paths[key] for key in sorted(unique_paths)]

with ExitStack() as stack:
for normalized_path in ordered_paths:
stack.enter_context(self.guard_path(normalized_path))
yield normalized_paths
Comment thread
codeacme17 marked this conversation as resolved.


GLOBAL_PATH_MUTATION_LOCKS = PathMutationLockRegistry()
107 changes: 65 additions & 42 deletions src/xagent/core/tools/adapters/vibe/browser_use.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import logging
import os
import uuid
from typing import Any, Mapping, Optional, Type

from pydantic import BaseModel, Field
Expand Down Expand Up @@ -34,6 +35,19 @@
_STEP_SESSION_ARG = "_xagent_step_id"


def _browser_output_filename(
output_filename: Any, *, prefix: str, extension: str
) -> str:
if output_filename:
return os.path.basename(str(output_filename))

from datetime import datetime

timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
unique_suffix = uuid.uuid4().hex[:8]
return f"{prefix}_{timestamp}_{unique_suffix}.{extension}"


class BrowserTaskSessionMixin:
"""Keeps browser tool default sessions aligned during task setup."""

Expand Down Expand Up @@ -324,6 +338,7 @@ class BrowserPdfResult(BaseModel):

class BrowserNavigateTool(BrowserTaskSessionMixin, AbstractBaseTool):
category = ToolCategory.BROWSER
concurrency_safe = True
"""Navigate to a URL in a browser session."""

def __init__(
Expand Down Expand Up @@ -491,6 +506,7 @@ async def teardown(self, task_id: Optional[str] = None) -> None:

class BrowserClickTool(BrowserTaskSessionMixin, AbstractBaseTool):
category = ToolCategory.BROWSER
concurrency_safe = True
"""Click an element on the current page."""

def __init__(self, task_id: Optional[str] = None):
Expand Down Expand Up @@ -539,6 +555,7 @@ async def run_json_async(self, args: Mapping[str, Any]) -> Any:

class BrowserFillTool(BrowserTaskSessionMixin, AbstractBaseTool):
category = ToolCategory.BROWSER
concurrency_safe = True
"""Fill an input field with text."""

def __init__(self, task_id: Optional[str] = None):
Expand Down Expand Up @@ -587,6 +604,7 @@ async def run_json_async(self, args: Mapping[str, Any]) -> Any:

class BrowserScreenshotTool(BrowserTaskSessionMixin, AbstractBaseTool):
category = ToolCategory.BROWSER
concurrency_safe = True
"""Take a screenshot of the current page."""

def __init__(
Expand Down Expand Up @@ -650,7 +668,6 @@ async def run_json_async(self, args: Mapping[str, Any]) -> Any:
if self._workspace and result.get("success"):
try:
import base64
from datetime import datetime

# Extract base64 data from data URI
screenshot_data = result.get("screenshot", "")
Expand All @@ -663,29 +680,30 @@ async def run_json_async(self, args: Mapping[str, Any]) -> Any:
image_bytes = base64.b64decode(base64_data)

# Determine filename - always save to output directory
output_filename = args.get("output_filename")
if output_filename:
# Sanitize filename to prevent path traversal attacks
filename = os.path.basename(output_filename)
else:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"screenshot_{timestamp}.png"
filename = _browser_output_filename(
args.get("output_filename"), prefix="screenshot", extension="png"
)
# Always save to output directory
file_path = self._workspace.output_dir / filename

# Save to file within auto_register context
with self._workspace.auto_register_files():
with open(file_path, "wb") as f:
f.write(image_bytes)

relative_path = str(
file_path.relative_to(self._workspace.workspace_dir)
)
file_ref = build_workspace_file_ref(
workspace=self._workspace,
file_path=file_path,
mime_type="image/png",
)
# Save and register under the shared path guard so concurrent
# explicit filenames cannot interleave writes or metadata.
with self._workspace.guard_workspace_mutation_path(
file_path
) as guarded_path:
file_path = guarded_path
with self._workspace.auto_register_files():
with open(file_path, "wb") as f:
f.write(image_bytes)

relative_path = str(
file_path.relative_to(self._workspace.workspace_dir)
)
file_ref = build_workspace_file_ref(
workspace=self._workspace,
file_path=file_path,
mime_type="image/png",
)
result["screenshot"] = relative_path
result["format"] = "file"
result["file_id"] = file_ref["file_id"]
Expand All @@ -707,6 +725,7 @@ async def run_json_async(self, args: Mapping[str, Any]) -> Any:

class BrowserExtractTextTool(BrowserTaskSessionMixin, AbstractBaseTool):
category = ToolCategory.BROWSER
concurrency_safe = True
"""Extract text content from the page."""

def __init__(self, task_id: Optional[str] = None):
Expand Down Expand Up @@ -754,6 +773,7 @@ async def run_json_async(self, args: Mapping[str, Any]) -> Any:

class BrowserEvaluateTool(BrowserTaskSessionMixin, AbstractBaseTool):
category = ToolCategory.BROWSER
concurrency_safe = True
"""Execute JavaScript code in the browser."""

def __init__(self, task_id: Optional[str] = None):
Expand Down Expand Up @@ -861,6 +881,7 @@ async def run_json_async(self, args: Mapping[str, Any]) -> Any:

class BrowserSelectOptionTool(BrowserTaskSessionMixin, AbstractBaseTool):
category = ToolCategory.BROWSER
concurrency_safe = True
"""Select an option from a dropdown."""

def __init__(self, task_id: Optional[str] = None):
Expand Down Expand Up @@ -910,6 +931,7 @@ async def run_json_async(self, args: Mapping[str, Any]) -> Any:

class BrowserWaitForSelectorTool(BrowserTaskSessionMixin, AbstractBaseTool):
category = ToolCategory.BROWSER
concurrency_safe = True
"""Wait for an element to appear on the page."""

def __init__(self, task_id: Optional[str] = None):
Expand Down Expand Up @@ -1001,6 +1023,7 @@ async def run_json_async(self, args: Mapping[str, Any]) -> Any:

class BrowserPdfTool(BrowserTaskSessionMixin, AbstractBaseTool):
category = ToolCategory.BROWSER
concurrency_safe = True
"""Save current page as PDF."""

def __init__(
Expand Down Expand Up @@ -1060,7 +1083,6 @@ async def run_json_async(self, args: Mapping[str, Any]) -> Any:
if self._workspace and result.get("success"):
try:
import base64
from datetime import datetime

# Extract base64 data
pdf_data = result.get("pdf", "")
Expand All @@ -1073,30 +1095,31 @@ async def run_json_async(self, args: Mapping[str, Any]) -> Any:
pdf_bytes = base64.b64decode(base64_data)

# Determine filename - always save to output directory
output_filename = args.get("output_filename")
if output_filename:
# Sanitize filename to prevent path traversal attacks
filename = os.path.basename(output_filename)
else:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"page_{timestamp}.pdf"
filename = _browser_output_filename(
args.get("output_filename"), prefix="page", extension="pdf"
)

# Always save to output directory
file_path = self._workspace.output_dir / filename

# Save to file within auto_register context
with self._workspace.auto_register_files():
with open(file_path, "wb") as f:
f.write(pdf_bytes)

relative_path = str(
file_path.relative_to(self._workspace.workspace_dir)
)
file_ref = build_workspace_file_ref(
workspace=self._workspace,
file_path=file_path,
mime_type="application/pdf",
)
# Save and register under the shared path guard so concurrent
# explicit filenames cannot interleave writes or metadata.
with self._workspace.guard_workspace_mutation_path(
file_path
) as guarded_path:
file_path = guarded_path
with self._workspace.auto_register_files():
with open(file_path, "wb") as f:
f.write(pdf_bytes)

relative_path = str(
file_path.relative_to(self._workspace.workspace_dir)
)
file_ref = build_workspace_file_ref(
workspace=self._workspace,
file_path=file_path,
mime_type="application/pdf",
)
result["output_path"] = relative_path
result["format"] = "file"
result["file_id"] = file_ref["file_id"]
Expand Down
4 changes: 4 additions & 0 deletions src/xagent/core/tools/adapters/vibe/document_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@


class DocumentParseTool(AbstractBaseTool):
read_only = True

def __init__(self, workspace: TaskWorkspace | None = None) -> None:
self._visibility = ToolVisibility.PUBLIC
self.workspace = workspace
Expand Down Expand Up @@ -60,6 +62,8 @@ async def run_json_async(self, args: Mapping[str, Any]) -> Any:

# This alternative tool will write the result to a file
class DocumentParseWithOutputTool(AbstractBaseTool):
concurrency_safe = True

def __init__(self, workspace: TaskWorkspace | None = None) -> None:
self._visibility = ToolVisibility.PUBLIC
self.workspace = workspace
Expand Down
34 changes: 28 additions & 6 deletions src/xagent/core/tools/adapters/vibe/file_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,34 @@ class FileTool(FunctionTool):
read_only=True,
)
write_file_tool = FileTool(
write_file, name="write_file", description="Write content to file"
write_file,
name="write_file",
description="Write content to file",
concurrency_safe=True,
)
append_file_tool = FileTool(
append_file, name="append_file", description="Append content to file"
append_file,
name="append_file",
description="Append content to file",
concurrency_safe=True,
)
delete_file_tool = FileTool(
delete_file,
name="delete_file",
description="Delete file",
concurrency_safe=True,
)
delete_file_tool = FileTool(delete_file, name="delete_file", description="Delete file")
list_files_tool = FileTool(
list_files,
name="list_files",
description="List files in directory",
read_only=True,
)
create_directory_tool = FileTool(
create_directory, name="create_directory", description="Create directory"
create_directory,
name="create_directory",
description="Create directory",
concurrency_safe=True,
)
file_exists_tool = FileTool(
file_exists,
Expand All @@ -79,7 +93,10 @@ class FileTool(FunctionTool):
read_only=True,
)
write_json_file_tool = FileTool(
write_json_file, name="write_json_file", description="Write JSON file"
write_json_file,
name="write_json_file",
description="Write JSON file",
concurrency_safe=True,
)
read_csv_file_tool = FileTool(
read_csv_file,
Expand All @@ -88,17 +105,22 @@ class FileTool(FunctionTool):
read_only=True,
)
write_csv_file_tool = FileTool(
write_csv_file, name="write_csv_file", description="Write CSV file"
write_csv_file,
name="write_csv_file",
description="Write CSV file",
concurrency_safe=True,
)
edit_file_tool = FileTool(
edit_file,
name="edit_file",
description="Precisely edit file content, supporting various editing operations based on line numbers and pattern matching",
concurrency_safe=True,
)
find_and_replace_tool = FileTool(
find_and_replace,
name="find_and_replace",
description="Convenient function to find and replace text content",
concurrency_safe=True,
)


Expand Down
Loading