Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions astrbot/core/astr_agent_tool_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@
BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT,
EXECUTE_SHELL_TOOL,
FILE_DOWNLOAD_TOOL,
FILE_EDIT_TOOL,
FILE_UPLOAD_TOOL,
GREP_TOOL,
LOCAL_EXECUTE_SHELL_TOOL,
LOCAL_PYTHON_TOOL,
PYTHON_TOOL,
READ_FILE_TOOL,
SEND_MESSAGE_TO_USER_TOOL,
)
from astrbot.core.cron.events import CronMessageEvent
Expand Down Expand Up @@ -184,11 +187,17 @@ def _get_runtime_computer_tools(cls, runtime: str) -> dict[str, FunctionTool]:
PYTHON_TOOL.name: PYTHON_TOOL,
FILE_UPLOAD_TOOL.name: FILE_UPLOAD_TOOL,
FILE_DOWNLOAD_TOOL.name: FILE_DOWNLOAD_TOOL,
READ_FILE_TOOL.name: READ_FILE_TOOL,
FILE_EDIT_TOOL.name: FILE_EDIT_TOOL,
GREP_TOOL.name: GREP_TOOL,
}
if runtime == "local":
return {
LOCAL_EXECUTE_SHELL_TOOL.name: LOCAL_EXECUTE_SHELL_TOOL,
LOCAL_PYTHON_TOOL.name: LOCAL_PYTHON_TOOL,
READ_FILE_TOOL.name: READ_FILE_TOOL,
FILE_EDIT_TOOL.name: FILE_EDIT_TOOL,
GREP_TOOL.name: GREP_TOOL,
}
return {}

Expand Down
9 changes: 9 additions & 0 deletions astrbot/core/astr_main_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@
EVALUATE_SKILL_CANDIDATE_TOOL,
EXECUTE_SHELL_TOOL,
FILE_DOWNLOAD_TOOL,
FILE_EDIT_TOOL,
FILE_UPLOAD_TOOL,
GET_EXECUTION_HISTORY_TOOL,
GET_SKILL_PAYLOAD_TOOL,
GREP_TOOL,
KNOWLEDGE_BASE_QUERY_TOOL,
LIST_SKILL_CANDIDATES_TOOL,
LIST_SKILL_RELEASES_TOOL,
Expand All @@ -41,6 +43,7 @@
LOCAL_PYTHON_TOOL,
PROMOTE_SKILL_CANDIDATE_TOOL,
PYTHON_TOOL,
READ_FILE_TOOL,
ROLLBACK_SKILL_RELEASE_TOOL,
RUN_BROWSER_SKILL_TOOL,
SANDBOX_MODE_PROMPT,
Expand Down Expand Up @@ -285,6 +288,9 @@ def _apply_local_env_tools(req: ProviderRequest) -> None:
req.func_tool = ToolSet()
req.func_tool.add_tool(LOCAL_EXECUTE_SHELL_TOOL)
req.func_tool.add_tool(LOCAL_PYTHON_TOOL)
req.func_tool.add_tool(READ_FILE_TOOL)
req.func_tool.add_tool(FILE_EDIT_TOOL)
req.func_tool.add_tool(GREP_TOOL)
req.system_prompt = f"{req.system_prompt or ''}\n{_build_local_mode_prompt()}\n"


Expand Down Expand Up @@ -991,6 +997,9 @@ def _apply_sandbox_tools(
req.func_tool.add_tool(PYTHON_TOOL)
req.func_tool.add_tool(FILE_UPLOAD_TOOL)
req.func_tool.add_tool(FILE_DOWNLOAD_TOOL)
req.func_tool.add_tool(READ_FILE_TOOL)
req.func_tool.add_tool(FILE_EDIT_TOOL)
req.func_tool.add_tool(GREP_TOOL)
if booter == "shipyard_neo":
# Neo-specific path rule: filesystem tools operate relative to sandbox
# workspace root. Do not prepend "/workspace".
Expand Down
6 changes: 6 additions & 0 deletions astrbot/core/astr_main_agent_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,17 @@
EvaluateSkillCandidateTool,
ExecuteShellTool,
FileDownloadTool,
FileEditTool,
FileUploadTool,
GetExecutionHistoryTool,
GetSkillPayloadTool,
GrepTool,
ListSkillCandidatesTool,
ListSkillReleasesTool,
LocalPythonTool,
PromoteSkillCandidateTool,
PythonTool,
ReadFileTool,
RollbackSkillReleaseTool,
RunBrowserSkillTool,
SyncSkillReleaseTool,
Expand Down Expand Up @@ -500,6 +503,9 @@ async def retrieve_knowledge_base(
LOCAL_PYTHON_TOOL = LocalPythonTool()
FILE_UPLOAD_TOOL = FileUploadTool()
FILE_DOWNLOAD_TOOL = FileDownloadTool()
READ_FILE_TOOL = ReadFileTool()
FILE_EDIT_TOOL = FileEditTool()
GREP_TOOL = GrepTool()
BROWSER_EXEC_TOOL = BrowserExecTool()
BROWSER_BATCH_EXEC_TOOL = BrowserBatchExecTool()
RUN_BROWSER_SKILL_TOOL = RunBrowserSkillTool()
Expand Down
12 changes: 8 additions & 4 deletions astrbot/core/computer/booters/boxlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@

import aiohttp
import boxlite
from shipyard.filesystem import FileSystemComponent as ShipyardFileSystemComponent
from shipyard import FileSystemComponent as ShipyardFileSystemComponent
from shipyard.python import PythonComponent as ShipyardPythonComponent
from shipyard.shell import ShellComponent as ShipyardShellComponent

from astrbot.api import logger

from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
from .shipyard import ShipyardFileSystemWrapper


class MockShipyardSandboxClient:
Expand Down Expand Up @@ -150,21 +151,24 @@ async def boot(self, session_id: str) -> None:
self.mocked = MockShipyardSandboxClient(
sb_url=f"http://127.0.0.1:{random_port}"
)
self._fs = ShipyardFileSystemComponent(
self._python = ShipyardPythonComponent(
client=self.mocked, # type: ignore
ship_id=self.box.id,
session_id=session_id,
)
self._python = ShipyardPythonComponent(
self._shell = ShipyardShellComponent(
client=self.mocked, # type: ignore
ship_id=self.box.id,
session_id=session_id,
)
self._shell = ShipyardShellComponent(
self._ship_fs = ShipyardFileSystemComponent(
client=self.mocked, # type: ignore
ship_id=self.box.id,
session_id=session_id,
)
self._fs = ShipyardFileSystemWrapper(
_shipyard_fs=self._ship_fs, _shipyard_shell=self._shell
)

await self.mocked.wait_healthy(self.box.id, session_id)

Expand Down
105 changes: 80 additions & 25 deletions astrbot/core/computer/booters/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,10 @@
from dataclasses import dataclass
from typing import Any

from python_ripgrep import search

from astrbot.api import logger
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_root,
get_astrbot_temp_path,
)
from astrbot.core.utils.astrbot_path import get_astrbot_root

from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
Expand All @@ -41,18 +39,6 @@ def _is_safe_command(command: str) -> bool:
return not any(pat in cmd for pat in _BLOCKED_COMMAND_PATTERNS)


def _ensure_safe_path(path: str) -> str:
abs_path = os.path.abspath(path)
allowed_roots = [
os.path.abspath(get_astrbot_root()),
os.path.abspath(get_astrbot_data_path()),
os.path.abspath(get_astrbot_temp_path()),
]
if not any(abs_path.startswith(root) for root in allowed_roots):
raise PermissionError("Path is outside the allowed computer roots.")
return abs_path


def _decode_bytes_with_fallback(
output: bytes | None,
*,
Expand Down Expand Up @@ -110,7 +96,7 @@ def _run() -> dict[str, Any]:
run_env = os.environ.copy()
if env:
run_env.update({str(k): str(v) for k, v in env.items()})
working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
working_dir = os.path.abspath(cwd) if cwd else get_astrbot_root()
if background:
# `command` is intentionally executed through the current shell so
# local computer-use behavior matches existing tool semantics.
Expand Down Expand Up @@ -186,7 +172,7 @@ async def create_file(
self, path: str, content: str = "", mode: int = 0o644
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
abs_path = os.path.abspath(path)
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
with open(abs_path, "w", encoding="utf-8") as f:
f.write(content)
Expand All @@ -195,24 +181,93 @@ def _run() -> dict[str, Any]:

return await asyncio.to_thread(_run)

async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
async def read_file(
self,
path: str,
encoding: str = "utf-8",
offset: int | None = None,
limit: int | None = None,
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
abs_path = os.path.abspath(path)
with open(abs_path, "rb") as f:
raw_content = f.read()
Comment on lines 193 to 194
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Reading the entire file into memory using f.read() is inefficient and risky for large files, potentially leading to Out-Of-Memory (OOM) errors. Since the tool supports offset and limit, consider reading the file in chunks or using f.seek() if the encoding allows, to only load the required portion of the file.

content = _decode_bytes_with_fallback(
raw_content,
preferred_encoding=encoding,
)
return {"success": True, "content": content}
start = 0 if offset is None else offset
content_slice = (
content[start:] if limit is None else content[start : start + limit]
)
return {
"success": True,
"content": content_slice,
}

return await asyncio.to_thread(_run)

async def search_files(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
after_context: int | None = None,
before_context: int | None = None,
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
results = search(
patterns=[pattern],
paths=[path] if path else None,
globs=[glob] if glob else None,
after_context=after_context,
before_context=before_context,
line_number=True,
)
return {"success": True, "content": "".join(results)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Joining all search results into a single string without any limit can consume significant memory if there are many matches. It would be safer to pass a result limit to the search function or truncate the results before joining.


return await asyncio.to_thread(_run)

async def edit_file(
self,
path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
encoding: str = "utf-8",
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = os.path.abspath(path)
with open(abs_path, encoding=encoding) as f:
content = f.read()
Comment on lines +241 to +242
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to read_file, reading the entire file into memory for string replacement can be problematic for large files. For better scalability, consider processing the file line-by-line or in chunks and writing to a temporary file.

occurrences = content.count(old_string)
if occurrences == 0:
return {
"success": False,
"error": "old string not found in file",
"replacements": 0,
}
if replace_all:
updated = content.replace(old_string, new_string)
replacements = occurrences
else:
updated = content.replace(old_string, new_string, 1)
replacements = 1
with open(abs_path, "w", encoding=encoding) as f:
f.write(updated)
return {
"success": True,
"path": abs_path,
"replacements": replacements,
}

return await asyncio.to_thread(_run)

async def write_file(
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
abs_path = os.path.abspath(path)
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
with open(abs_path, mode, encoding=encoding) as f:
f.write(content)
Expand All @@ -222,7 +277,7 @@ def _run() -> dict[str, Any]:

async def delete_file(self, path: str) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
abs_path = os.path.abspath(path)
if os.path.isdir(abs_path):
shutil.rmtree(abs_path)
else:
Expand All @@ -235,7 +290,7 @@ async def list_dir(
self, path: str = ".", show_hidden: bool = False
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
abs_path = os.path.abspath(path)
entries = os.listdir(abs_path)
if not show_hidden:
entries = [e for e in entries if not e.startswith(".")]
Expand Down
Loading
Loading