Merge origin/main into feat/best_skill_and_hook (resolve 4 conflicts)

Made-with: Cursor
This commit is contained in:
Xubin Ren
2026-04-05 18:53:17 +00:00
186 changed files with 28653 additions and 5376 deletions
+41 -27
View File
@@ -3,13 +3,34 @@
import asyncio
import os
import re
import sys
from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import Tool
from loguru import logger
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.sandbox import wrap_command
from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.config.paths import get_media_dir
@tool_parameters(
tool_parameters_schema(
command=StringSchema("The shell command to execute"),
working_dir=StringSchema("Optional working directory for the command"),
timeout=IntegerSchema(
60,
description=(
"Timeout in seconds. Increase for long-running commands "
"like compilation or installation (default 60, max 600)."
),
minimum=1,
maximum=600,
),
required=["command"],
)
)
class ExecTool(Tool):
"""Tool to execute shell commands."""
@@ -53,30 +74,8 @@ class ExecTool(Tool):
return "Execute a shell command and return its output. Use with caution."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute",
},
"working_dir": {
"type": "string",
"description": "Optional working directory for the command",
},
"timeout": {
"type": "integer",
"description": (
"Timeout in seconds. Increase for long-running commands "
"like compilation or installation (default 60, max 600)."
),
"minimum": 1,
"maximum": 600,
},
},
"required": ["command"],
}
def exclusive(self) -> bool:
return True
async def execute(
self, command: str, working_dir: str | None = None,
@@ -118,6 +117,12 @@ class ExecTool(Tool):
await asyncio.wait_for(process.wait(), timeout=5.0)
except asyncio.TimeoutError:
pass
finally:
if sys.platform != "win32":
try:
os.waitpid(process.pid, os.WNOHANG)
except (ProcessLookupError, ChildProcessError) as e:
logger.debug("Process already reaped or not found: {}", e)
return f"Error: Command timed out after {effective_timeout} seconds"
output_parts = []
@@ -178,14 +183,23 @@ class ExecTool(Tool):
p = Path(expanded).expanduser().resolve()
except Exception:
continue
if p.is_absolute() and cwd_path not in p.parents and p != cwd_path:
media_path = get_media_dir().resolve()
if (p.is_absolute()
and cwd_path not in p.parents
and p != cwd_path
and media_path not in p.parents
and p != media_path
):
return "Error: Command blocked by safety guard (path outside working dir)"
return None
@staticmethod
def _extract_absolute_paths(command: str) -> list[str]:
win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]+", command) # Windows: C:\...
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]*", command)
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
home_paths = re.findall(r"(?:^|[\s|>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
return win_paths + posix_paths + home_paths