Files

1 line
48 KiB
JSON
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "barcelona517/prosec", "files": {"tooling.py": {"ac965350d662a317758446ccb4eaa676539647c7": {"t": 1785768143.0948646, "n": 46067, "h": "545a9de0596cd9bc", "c": "from __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\nfrom typing import Any, Callable\nimport json\nimport os\nimport re\nimport shutil\nimport subprocess\nimport urllib.error\nimport urllib.parse\nimport urllib.request\nimport zipfile\nimport xml.etree.ElementTree as ET\n\n\nclass ToolExecutionError(Exception):\n pass\n\n\n_GLOBAL_NODE_MODULES_PATH: str | None = None\n\n\ndef _resolve_global_node_modules_path() -> str | None:\n global _GLOBAL_NODE_MODULES_PATH\n if _GLOBAL_NODE_MODULES_PATH is not None:\n return _GLOBAL_NODE_MODULES_PATH\n\n npm_cmd = shutil.which(\"npm.cmd\") or shutil.which(\"npm\")\n if not npm_cmd:\n _GLOBAL_NODE_MODULES_PATH = None\n return None\n\n try:\n completed = subprocess.run(\n [npm_cmd, \"root\", \"-g\"],\n capture_output=True,\n text=True,\n encoding=\"utf-8\",\n errors=\"replace\",\n timeout=10,\n shell=False,\n )\n except Exception:\n _GLOBAL_NODE_MODULES_PATH = None\n return None\n\n if completed.returncode != 0:\n _GLOBAL_NODE_MODULES_PATH = None\n return None\n\n value = completed.stdout.strip()\n _GLOBAL_NODE_MODULES_PATH = value or None\n return _GLOBAL_NODE_MODULES_PATH\n\n\ndef safe_resolve_path(root: Path, user_path: str) -> Path:\n candidate = (root / user_path).resolve() if not Path(user_path).is_absolute() else Path(user_path).resolve()\n try:\n candidate.relative_to(root)\n except ValueError as exc:\n raise ToolExecutionError(f\"Path out of workspace: {candidate}; allowed root: {root}\") from exc\n return candidate\n\n\n@dataclass\nclass Tool:\n name: str\n description: str\n input_schema: dict[str, Any]\n handler: Callable[[dict[str, Any]], str]\n\n\nclass ToolRegistry:\n def __init__(self, root: Path):\n self.root = root\n self._tools: dict[str, Tool] = {}\n self._register_builtin_tools()\n\n def _register_builtin_tools(self) -> None:\n self.register(\n Tool(\n name=\"get_current_time\",\n description=\"Get local time, or time at a specific UTC offset.\",\n input_schema={\n \"type\": \"object\",\n \"properties\": {\n \"utc_offset\": {\n \"type\": \"string\",\n \"description\": \"Optional UTC offset like +08:00 or -05:00.\",\n }\n },\n \"additionalProperties\": False,\n },\n handler=self._get_current_time,\n )\n )\n\n self.register(\n Tool(\n name=\"get_weather\",\n description=\"Get current weather and short forecast for a location.\",\n input_schema={\n \"type\": \"object\",\n \"properties\": {\n \"location\": {\n \"type\": \"string\",\n \"description\": \"City or place name.\",\n }\n },\n \"required\": [\"location\"],\n \"additionalProperties\": False,\n },\n handler=self._get_weather,\n )\n )\n\n self.register(\n Tool(\n name=\"search_web\",\n description=\"Search the web for a topic or keyword and return concise results.\",\n input_schema={\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\n \"type\": \"string\",\n \"description\": \"Keyword, noun, or question.\",\n },\n \"max_results\": {\n \"type\": \"integer\",\n \"description\": \"Maximum number of results, default 5.\",\n \"minimum\": 1,\n \"maximum\": 10,\n },\n },\n \"required\": [\"query\"],\n \"additionalProperties\": False,\n },\n handler=self._search_web,\n )\n )\n\n self.register(\n Tool(\n name=\"list_files\",\n description=\"List files and sub-directories under a workspace-relative directory.\",\n input_schema={\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\",\n \"description\": \"Workspace-relative directory path, default '.'.\",\n }\n },\n \"required\": [],\n \"additionalProperties\": False,\n },\n handler=self._list_files,\n )\n )\n\n self.register(\n Tool(\n name=\"read_text_file\",\n description=\"Read content from text-like files. Supports txt/md/json/csv/tsv/pdf/docx/xlsx/pptx.\",\n input_schema={\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Workspace-relative file path.\"},\n \"max_chars\": {\n \"type\": \"integer\",\n \"description\": \"Maximum chars to return, default 4000.\",\n \"minimum\": 100,\n \"maximum\": 20000,\n },\n },\n \"required\": [\"path\"],\n \"additionalProperties\": False,\n },\n handler=self._read_text_file,\n )\n )\n\n self.register(\n Tool(\n name=\"write_text_file\",\n description=\"Write text content to a workspace file, overwrite or append.\",\n input_schema={\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\"type\": \"string\", \"description\": \"Workspace-relative file path.\"},\n \"content\": {\"type\": \"string\", \"description\": \"Text content to write.\"},\n \"mode\": {\n \"type\": \"string\",\n \"enum\": [\"overwrite\", \"append\"],\n \"description\": \"overwrite replaces, append appends.\",\n },\n },\n \"required\": [\"path\", \"content\"],\n \"additionalProperties\": False,\n },\n handler=self._write_text_file,\n )\n )\n self.register(\n Tool(\n name=\"run_shell_command\",\n description=(\n \"Run a strictly validated shell command from an allow-list. \"\n \"Only safe read-oriented commands are supported.\"\n ),\n input_schema={\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\n \"type\": \"array\",\n \"description\": \"Command and args as tokenized string list, e.g. ['python','--version'].\",\n \"items\": {\"type\": \"string\"},\n \"minItems\": 1,\n \"maxItems\": 20,\n },\n \"timeout_seconds\": {\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 20,\n \"description\": \"Execution timeout in seconds. Default 8.\",\n },\n },\n \"required\": [\"command\"],\n \"additionalProperties\": False,\n },\n handler=self._run_shell_command,\n )\n )\n self.register(\n Tool(\n name=\"delegate_subagent\",\n description=(\n \"Delegate a focused analysis subtask to a lightweight sub-agent. \"\n \"Useful for summarizing uploaded files or extracting key points.\"\n ),\n input_schema={\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\"type\": \"string\", \"description\": \"The subtask to delegate.\"},\n \"files\": {\n \"type\": \"array\",\n \"items\": {\"type\": \"string\"},\n \"description\": \"Optional workspace-relative file paths for sub-agent analysis.\",\n },\n },\n \"required\": [\"task\"],\n \"additionalProperties\": False,\n },\n handler=self._delegate_subagent,\n )\n )\n\n def register(self, tool: Tool) -> None:\n self._tools[tool.name] = tool\n\n def all_for_openai(self) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": tool.name,\n \"description\": tool.description,\n \"parameters\": tool.input_schema,\n },\n }\n for tool in self._tools.values()\n ]\n\n def execute(self, name: str, raw_arguments: str) -> str:\n if name not in self._tools:\n raise ToolExecutionError(f\"Unknown tool: {name}\")\n\n try:\n arguments = json.loads(raw_arguments or \"{}\")\n if not isinstance(arguments, dict):\n raise ToolExecutionError(\"Tool arguments must be a JSON object\")\n except json.JSONDecodeError as exc:\n raise ToolExecutionError(f\"Invalid JSON arguments: {exc}\") from exc\n\n return self._tools[name].handler(arguments)\n\n def _list_files(self, args: dict[str, Any]) -> str:\n rel_path = args.get(\"path\", \".\")\n path = safe_resolve_path(self.root, rel_path)\n if not path.exists():\n raise ToolExecutionError(f\"Path does not exist: {rel_path}\")\n if not path.is_dir():\n raise ToolExecutionError(f\"Target is not a directory: {rel_path}\")\n\n items = []\n for p in sorted(path.iterdir(), key=lambda x: x.name.lower()):\n kind = \"dir\" if p.is_dir() else \"file\"\n items.append({\"name\": p.name, \"type\": kind})\n return json.dumps({\"path\": str(path), \"items\": items}, ensure_ascii=False)\n\n def _read_text_file(self, args: dict[str, Any]) -> str:\n rel_path = args[\"path\"]\n max_chars = int(args.get(\"max_chars\", 4000))\n path = safe_resolve_path(self.root, rel_path)\n\n if not path.exists():\n raise ToolExecutionError(f\"File does not exist: {rel_path}\")\n if not path.is_file():\n raise ToolExecutionError(f\"Target is not a file: {rel_path}\")\n\n content, detected_format = self._read_file_content(path)\n clipped = content[:max_chars]\n return json.dumps(\n {\n \"path\": str(path),\n \"detected_format\": detected_format,\n \"content\": clipped,\n \"truncated\": len(content) > len(clipped),\n \"total_chars\": len(content),\n },\n ensure_ascii=False,\n )\n\n def _read_file_content(self, path: Path) -> tuple[str, str]:\n suffix = path.suffix.lower()\n if suffix == \".pdf\":\n return self._read_pdf(path), \"pdf\"\n if suffix == \".docx\":\n return self._read_docx(path), \"docx\"\n if suffix == \".xlsx\":\n return self._read_xlsx(path), \"xlsx\"\n if suffix == \".pptx\":\n return self._read_pptx(path), \"pptx\"\n if suffix in {\".csv\", \".tsv\"}:\n return self._read_csv_like(path), suffix.lstrip(\".\")\n if suffix == \".json\":\n return self._read_json(path), \"json\"\n\n raw = path.read_bytes()\n if self._looks_binary(raw):\n raise ToolExecutionError(\n f\"Unsupported binary file for direct reading: {path.name}. Supported: text/JSON/CSV/TSV/PDF/DOCX/XLSX/PPTX.\"\n )\n\n for enc in (\"utf-8\", \"utf-8-sig\", \"gb18030\", \"gbk\", \"utf-16\", \"utf-16-le\", \"utf-16-be\"):\n try:\n return raw.decode(enc), f\"text({enc})\"\n except UnicodeDecodeError:\n continue\n\n raise ToolExecutionError(f\"Unable to decode file: {path.name}. Please convert to UTF-8 text.\")\n\n def _looks_binary(self, raw: bytes) -> bool:\n if not raw:\n return False\n if b\"\\x00\" in raw:\n return True\n sample = raw[:4096]\n ctrl = sum(1 for b in sample if b < 9 or (13 < b < 32))\n return (ctrl / max(1, len(sample))) > 0.12\n\n def _read_json(self, path: Path) -> str:\n for enc in (\"utf-8\", \"utf-8-sig\", \"gb18030\", \"gbk\", \"utf-16\"):\n try:\n text = path.read_text(encoding=enc)\n obj = json.loads(text)\n return json.dumps(obj, ensure_ascii=False, indent=2)\n except UnicodeDecodeError:\n continue\n except json.JSONDecodeError:\n break\n raise ToolExecutionError(f\"JSON parse failed: {path.name}\")\n\n def _read_csv_like(self, path: Path) -> str:\n for enc in (\"utf-8\", \"utf-8-sig\", \"gb18030\", \"gbk\"):\n try:\n return path.read_text(encoding=enc)\n except UnicodeDecodeError:\n continue\n raise ToolExecutionError(f\"CSV/TSV encoding not recognized: {path.name}\")\n\n def _read_pdf(self, path: Path) -> str:\n try:\n from pypdf import PdfReader # type: ignore\n except Exception as exc:\n raise ToolExecutionError(\"PDF reader dependency missing. Install `pypdf`.\") from exc\n\n try:\n reader = PdfReader(str(path))\n parts: list[str] = []\n for page in reader.pages:\n t = (page.extract_text() or \"\").strip()\n if t:\n parts.append(t)\n return \"\\n\\n\".join(parts).strip()\n except Exception as exc:\n raise ToolExecutionError(f\"PDF parse failed: {path.name}\") from exc\n\n def _read_docx(self, path: Path) -> str:\n try:\n from docx import Document # type: ignore\n\n doc = Document(str(path))\n lines = [p.text.strip() for p in doc.paragraphs if (p.text or \"\").strip()]\n if lines:\n return \"\\n\".join(lines)\n except Exception:\n pass\n\n try:\n with zipfile.ZipFile(path) as zf:\n xml_bytes = zf.read(\"word/document.xml\")\n except Exception as exc:\n raise ToolExecutionError(f\"DOCX read failed: {path.name}\") from exc\n\n try:\n root = ET.fromstring(xml_bytes)\n except ET.ParseError as exc:\n raise ToolExecutionError(f\"DOCX parse failed: {path.name}\") from exc\n\n ns = {\"w\": \"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"}\n lines: list[str] = []\n for para in root.findall(\".//w:p\", ns):\n texts = [t.text or \"\" for t in para.findall(\".//w:t\", ns)]\n line = \"\".join(texts).strip()\n if line:\n lines.append(line)\n return \"\\n\".join(lines)\n\n def _read_xlsx(self, path: Path) -> str:\n try:\n from openpyxl import load_workbook # type: ignore\n\n wb = load_workbook(filename=str(path), read_only=True, data_only=True)\n out: list[str] = []\n for ws in wb.worksheets:\n out.append(f\"[{ws.title}]\")\n for row in ws.iter_rows(values_only=True):\n values = [\"\" if v is None else str(v) for v in row]\n if any(v.strip() for v in values):\n out.append(\"\\t\".join(values))\n content = \"\\n\".join(out).strip()\n if content:\n return content\n except Exception:\n pass\n\n try:\n with zipfile.ZipFile(path) as zf:\n shared = self._xlsx_shared_strings(zf)\n sheets = sorted(\n n\n for n in zf.namelist()\n if n.startswith(\"xl/worksheets/sheet\") and n.endswith(\".xml\")\n )\n out: list[str] = []\n for sheet in sheets:\n out.append(f\"[{Path(sheet).name}]\")\n out.extend(self._xlsx_sheet_rows(zf.read(sheet), shared))\n return \"\\n\".join(out).strip()\n except Exception as exc:\n raise ToolExecutionError(f\"XLSX parse failed: {path.name}\") from exc\n\n def _xlsx_shared_strings(self, zf: zipfile.ZipFile) -> list[str]:\n if \"xl/sharedStrings.xml\" not in zf.namelist():\n return []\n root = ET.fromstring(zf.read(\"xl/sharedStrings.xml\"))\n ns = {\"s\": \"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"}\n vals: list[str] = []\n for si in root.findall(\".//s:si\", ns):\n vals.append(\"\".join((t.text or \"\") for t in si.findall(\".//s:t\", ns)))\n return vals\n\n def _xlsx_sheet_rows(self, xml_bytes: bytes, shared: list[str]) -> list[str]:\n root = ET.fromstring(xml_bytes)\n ns = {\"s\": \"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"}\n rows: list[str] = []\n for row in root.findall(\".//s:row\", ns):\n vals: list[str] = []\n for cell in row.findall(\"s:c\", ns):\n cell_type = cell.attrib.get(\"t\")\n v = cell.find(\"s:v\", ns)\n if v is None or v.text is None:\n vals.append(\"\")\n continue\n txt = v.text\n if cell_type == \"s\":\n try:\n txt = shared[int(txt)]\n except Exception:\n pass\n vals.append(txt)\n if any(v.strip() for v in vals):\n rows.append(\"\\t\".join(vals))\n return rows\n\n def _read_pptx(self, path: Path) -> str:\n try:\n from pptx import Presentation # type: ignore\n\n prs = Presentation(str(path))\n out: list[str] = []\n for idx, slide in enumerate(prs.slides, start=1):\n out.append(f\"[slide{idx}]\")\n for shape in slide.shapes:\n text = (getattr(shape, \"text\", \"\") or \"\").strip()\n if text:\n out.append(text)\n content = \"\\n\".join(out).strip()\n if content:\n return content\n except Exception:\n pass\n\n try:\n with zipfile.ZipFile(path) as zf:\n slides = sorted(\n n\n for n in zf.namelist()\n if n.startswith(\"ppt/slides/slide\") and n.endswith(\".xml\")\n )\n out: list[str] = []\n for slide in slides:\n out.append(f\"[{Path(slide).name}]\")\n out.extend(self._pptx_slide_lines(zf.read(slide)))\n return \"\\n\".join(out).strip()\n except Exception as exc:\n raise ToolExecutionError(f\"PPTX parse failed: {path.name}\") from exc\n\n def _pptx_slide_lines(self, xml_bytes: bytes) -> list[str]:\n root = ET.fromstring(xml_bytes)\n ns = {\"a\": \"http://schemas.openxmlformats.org/drawingml/2006/main\"}\n lines: list[str] = []\n for p in root.findall(\".//a:p\", ns):\n line = \"\".join((t.text or \"\") for t in p.findall(\".//a:t\", ns)).strip()\n if line:\n lines.append(line)\n return lines\n\n def _write_text_file(self, args: dict[str, Any]) -> str:\n rel_path = args[\"path\"]\n content = args[\"content\"]\n mode = args.get(\"mode\", \"overwrite\")\n\n path = safe_resolve_path(self.root, rel_path)\n path.parent.mkdir(parents=True, exist_ok=True)\n\n if mode == \"append\":\n with path.open(\"a\", encoding=\"utf-8\") as f:\n f.write(content)\n else:\n path.write_text(content, encoding=\"utf-8\")\n\n return json.dumps({\"path\": str(path), \"mode\": mode, \"written_chars\": len(content)}, ensure_ascii=False)\n\n def _run_shell_command(self, args: dict[str, Any]) -> str:\n cmd = args.get(\"command\")\n if not isinstance(cmd, list) or not cmd or not all(isinstance(x, str) and x.strip() for x in cmd):\n raise ToolExecutionError(\"command must be a non-empty string array\")\n\n timeout_seconds = int(args.get(\"timeout_seconds\", 20 if str(cmd[0]).lower() in {\"npm\", \"npx\"} else 8))\n if timeout_seconds < 1 or timeout_seconds > 20:\n raise ToolExecutionError(\"timeout_seconds must be in [1, 20]\")\n\n command = [x.strip() for x in cmd]\n program = command[0].lower()\n allow_node_eval = program == \"node\" and len(command) >= 2 and command[1] in {\"-e\", \"--eval\", \"-p\", \"--print\"}\n blocked_chars = {\"|\", \"&&\", \"||\", \";\", \">\", \"<\", \"$(\", \"`\"}\n if not allow_node_eval:\n for token in command:\n if any(sym in token for sym in blocked_chars):\n raise ToolExecutionError(\"shell meta characters are not allowed\")\n\n allowed = {\n \"python\": {\"--version\", \"-V\", \"-m\", \"scripts/office/soffice.py\", \"scripts/office/unpack.py\", \"scripts/office/pack.py\", \"scripts/clean.py\", \"scripts/thumbnail.py\", \"scripts/add_slide.py\"},\n \"py\": {\"--version\", \"-V\"},\n \"node\": {\"--version\", \"-v\"},\n \"npm\": {\"--version\", \"-v\", \"install\", \"list\", \"view\"},\n \"npx\": {\"--version\", \"-v\", \"pptxgenjs\"},\n \"pdftoppm\": None,\n \"where\": None,\n \"whoami\": None,\n \"dir\": None,\n }\n if program not in allowed:\n raise ToolExecutionError(f\"command not allowed: {program}\")\n\n if program in {\"python\", \"py\"}:\n if len(command) == 1:\n raise ToolExecutionError(\"python command requires explicit safe args\")\n if command[1] not in allowed[program]:\n raise ToolExecutionError(f\"python arg not allowed: {command[1]}\")\n if command[1:3] == [\"-m\", \"pip\"]:\n if len(command) < 4 or command[3] not in {\"show\"}:\n raise ToolExecutionError(\"only 'python -m pip show <pkg>' is allowed\")\n if len(command) != 5:\n raise ToolExecutionError(\"pip show requires exactly one package name\")\n pkg = command[4]\n if not re.fullmatch(r\"[A-Za-z0-9_.-]{1,80}\", pkg):\n raise ToolExecutionError(\"invalid package name for pip show\")\n elif command[1:3] == [\"-m\", \"markitdown\"]:\n if len(command) != 4:\n raise ToolExecutionError(\"python -m markitdown requires exactly one file path\")\n elif command[1].replace(\"\\\\\", \"/\") in {\n \"scripts/office/soffice.py\",\n \"scripts/office/unpack.py\",\n \"scripts/office/pack.py\",\n \"scripts/clean.py\",\n \"scripts/thumbnail.py\",\n \"scripts/add_slide.py\",\n }:\n if len(command) < 3:\n raise ToolExecutionError(\"python script invocation requires arguments\")\n elif len(command) > 2:\n raise ToolExecutionError(\"python only allows --version / -V, python -m pip show <pkg>, python -m markitdown <file>, or approved workspace scripts\")\n\n if program == \"node\":\n if len(command) == 2 and command[1] in {\"--version\", \"-v\"}:\n pass\n elif allow_node_eval:\n if len(command) < 3:\n raise ToolExecutionError(\"node eval requires a code string\")\n if len(command) > 4 and command[1] in {\"-p\", \"--print\"}:\n raise ToolExecutionError(\"node print/eval invocation has too many arguments\")\n elif len(command) >= 2 and command[1].lower().endswith((\".js\", \".mjs\", \".cjs\")):\n script_path = safe_resolve_path(self.root, command[1])\n if not script_path.exists() or not script_path.is_file():\n raise ToolExecutionError(f\"node script not found: {command[1]}\")\n if len(command) > 16:\n raise ToolExecutionError(\"node script invocation has too many arguments\")\n else:\n raise ToolExecutionError(\"node only allows --version, -v, -e/--eval, -p/--print, or a workspace script file\")\n\n if program == \"npm\":\n if len(command) == 2 and command[1] in {\"--version\", \"-v\"}:\n pass\n elif command[1:3] == [\"install\", \"pptxgenjs\"] and len(command) == 3:\n pass\n elif command[1:4] == [\"install\", \"-g\", \"pptxgenjs\"] and len(command) == 4:\n pass\n elif command[1:3] == [\"root\", \"-g\"] and len(command) == 3:\n pass\n elif command[1:3] == [\"list\", \"pptxgenjs\"] and len(command) == 3:\n pass\n elif command[1:4] == [\"list\", \"-g\", \"pptxgenjs\"] and len(command) == 4:\n pass\n elif command[1:3] == [\"view\", \"pptxgenjs\"] and len(command) in {3, 4}:\n pass\n else:\n raise ToolExecutionError(\"npm only allows version checks, pptxgenjs install/list/view, or the -g install form\")\n\n if program == \"npx\":\n if len(command) == 2 and command[1] in {\"--version\", \"-v\"}:\n pass\n elif command[1] == \"pptxgenjs\":\n if len(command) > 16:\n raise ToolExecutionError(\"npx pptxgenjs invocation has too many arguments\")\n else:\n raise ToolExecutionError(\"npx only allows version checks or pptxgenjs\")\n\n if program == \"pdftoppm\":\n if len(command) < 3:\n raise ToolExecutionError(\"pdftoppm requires flags, input PDF, and output prefix\")\n if len(command) > 12:\n raise ToolExecutionError(\"pdftoppm invocation has too many arguments\")\n\n if program == \"where\":\n if len(command) != 2:\n raise ToolExecutionError(\"where requires exactly one executable name\")\n if not re.fullmatch(r\"[A-Za-z0-9_.-]{1,80}\", command[1]):\n raise ToolExecutionError(\"invalid target for where\")\n\n if program in {\"whoami\", \"dir\"} and len(command) != 1:\n raise ToolExecutionError(f\"{program} does not accept extra args in sandbox mode\")\n\n if program == \"dir\":\n # Use PowerShell Get-ChildItem instead of cmd built-in.\n ps_cmd = [\"powershell\", \"-NoProfile\", \"-Command\", \"Get-ChildItem -Name\"]\n try:\n completed = subprocess.run(\n ps_cmd,\n cwd=str(self.root),\n capture_output=True,\n text=True,\n encoding=\"utf-8\",\n errors=\"replace\",\n timeout=timeout_seconds,\n shell=False,\n )\n except subprocess.TimeoutExpired as exc:\n raise ToolExecutionError(f\"command timeout after {timeout_seconds}s\") from exc\n else:\n if program in {\"npm\", \"npx\"}:\n resolved = shutil.which(f\"{program}.cmd\") or shutil.which(program) or program\n command = [resolved, *command[1:]]\n env = os.environ.copy()\n if program in {\"node\", \"npm\", \"npx\"}:\n global_node_modules = _resolve_global_node_modules_path()\n if global_node_modules:\n existing_node_path = env.get(\"NODE_PATH\", \"\").strip()\n env[\"NODE_PATH\"] = (\n f\"{existing_node_path};{global_node_modules}\" if existing_node_path else global_node_modules\n )\n try:\n completed = subprocess.run(\n command,\n cwd=str(self.root),\n capture_output=True,\n text=True,\n encoding=\"utf-8\",\n errors=\"replace\",\n timeout=timeout_seconds,\n shell=False,\n env=env,\n )\n except subprocess.TimeoutExpired as exc:\n raise ToolExecutionError(f\"command timeout after {timeout_seconds}s\") from exc\n\n return json.dumps(\n {\n \"ok\": completed.returncode == 0,\n \"returncode\": completed.returncode,\n \"stdout\": completed.stdout[:6000],\n \"stderr\": completed.stderr[:3000],\n },\n ensure_ascii=False,\n )\n\n def _delegate_subagent(self, args: dict[str, Any]) -> str:\n from llm_client import build_client\n from config import MODEL_NAME\n\n task = str(args.get(\"task\", \"\")).strip()\n if not task:\n raise ToolExecutionError(\"task cannot be empty\")\n files = args.get(\"files\", [])\n if files is None:\n files = []\n if not isinstance(files, list):\n raise ToolExecutionError(\"files must be a string array\")\n\n snippets: list[str] = []\n for rel in files[:5]:\n if not isinstance(rel, str) or not rel.strip():\n continue\n try:\n raw = self._read_text_file({\"path\": rel.strip(), \"max_chars\": 2500})\n payload = json.loads(raw)\n snippets.append(\n f\"[{rel.strip()} | {payload.get('detected_format','text')}]\\n{payload.get('content','')}\"\n )\n except Exception as exc: # noqa: BLE001\n snippets.append(f\"[{rel.strip()}] read failed: {exc}\")\n\n system_prompt = (\n \"You are a delegated sub-agent. Solve only the requested subtask clearly and briefly. \"\n \"If file snippets are provided, prioritize them.\"\n )\n user_prompt = task\n if snippets:\n user_prompt += \"\\n\\nFile snippets:\\n\" + \"\\n\\n---\\n\\n\".join(snippets)\n\n client = build_client()\n resp = client.chat.completions.create(\n model=MODEL_NAME,\n messages=[\n {\"role\": \"system\", \"content\": system_prompt},\n {\"role\": \"user\", \"content\": user_prompt},\n ],\n temperature=0.2,\n )\n answer = (resp.choices[0].message.content or \"\").strip()\n return json.dumps(\n {\n \"ok\": True,\n \"task\": task,\n \"used_files\": files[:5],\n \"answer\": answer,\n },\n ensure_ascii=False,\n )\n\n def _get_current_time(self, args: dict[str, Any]) -> str:\n utc_offset = str(args.get(\"utc_offset\", \"\") or \"\").strip()\n if utc_offset:\n tz = self._parse_utc_offset(utc_offset)\n now = datetime.now(tz)\n return json.dumps(\n {\n \"timezone_mode\": \"utc_offset\",\n \"utc_offset\": utc_offset,\n \"iso\": now.isoformat(timespec=\"seconds\"),\n \"date\": now.strftime(\"%Y-%m-%d\"),\n \"time\": now.strftime(\"%H:%M:%S\"),\n \"weekday\": now.strftime(\"%A\"),\n },\n ensure_ascii=False,\n )\n\n now = datetime.now().astimezone()\n offset = now.strftime(\"%z\")\n normalized_offset = f\"{offset[:3]}:{offset[3:]}\" if offset else \"\"\n return json.dumps(\n {\n \"timezone_mode\": \"local\",\n \"utc_offset\": normalized_offset,\n \"iso\": now.isoformat(timespec=\"seconds\"),\n \"date\": now.strftime(\"%Y-%m-%d\"),\n \"time\": now.strftime(\"%H:%M:%S\"),\n \"weekday\": now.strftime(\"%A\"),\n },\n ensure_ascii=False,\n )\n\n def _parse_utc_offset(self, value: str) -> timezone:\n match = re.fullmatch(r\"([+-])(\\d{2}):(\\d{2})\", value)\n if not match:\n raise ToolExecutionError(\"utc_offset must be like +08:00 or -05:00\")\n sign, hours_str, minutes_str = match.groups()\n hours = int(hours_str)\n minutes = int(minutes_str)\n if hours > 23 or minutes > 59:\n raise ToolExecutionError(\"utc_offset out of valid range\")\n total_minutes = hours * 60 + minutes\n if sign == \"-\":\n total_minutes = -total_minutes\n return timezone(timedelta(minutes=total_minutes))\n\n def _search_web(self, args: dict[str, Any]) -> str:\n query = str(args[\"query\"]).strip()\n max_results = int(args.get(\"max_results\", 5))\n if not query:\n raise ToolExecutionError(\"search query cannot be empty\")\n\n search_360_key = (\n os.getenv(\"SEARCH360_API_KEY\")\n or os.getenv(\"QIHOO360_API_KEY\")\n or os.getenv(\"AI360_API_KEY\")\n )\n if search_360_key:\n return self._search_with_360(query, max_results, search_360_key)\n\n serper_key = os.getenv(\"SERPER_API_KEY\")\n if serper_key:\n return self._search_with_serper(query, max_results, serper_key)\n\n return self._search_with_duckduckgo(query, max_results)\n\n def _search_with_360(self, query: str, max_results: int, api_key: str) -> str:\n payload = json.dumps(\n {\n \"model\": \"360gpt-pro\",\n \"messages\": [{\"role\": \"user\", \"content\": query}],\n \"max_refer_search_items\": max(1, min(max_results, 10)),\n \"enable_corner_markers\": True,\n \"enable_web_page_safety\": True,\n \"stream\": False,\n }\n ).encode(\"utf-8\")\n request = urllib.request.Request(\n \"https://api.360.cn/v1/search/aisearch\",\n data=payload,\n headers={\n \"Authorization\": f\"Bearer {api_key}\",\n \"Content-Type\": \"application/json\",\n },\n method=\"POST\",\n )\n data = self._load_json_response(request, \"360 search\")\n\n answer = self._extract_360_answer_text(data)\n results = self._extract_360_references(data, max_results)\n if not answer and not results:\n raise ToolExecutionError(\"360 search returned no usable content\")\n\n return json.dumps(\n {\n \"query\": query,\n \"provider\": \"360_aisearch\",\n \"answer\": answer,\n \"results\": results,\n \"raw\": data,\n },\n ensure_ascii=False,\n )\n\n def _extract_360_answer_text(self, data: dict[str, Any]) -> str:\n candidates: list[str] = []\n\n if isinstance(data.get(\"answer\"), str):\n candidates.append(str(data[\"answer\"]))\n if isinstance(data.get(\"content\"), str):\n candidates.append(str(data[\"content\"]))\n if isinstance(data.get(\"output_text\"), str):\n candidates.append(str(data[\"output_text\"]))\n\n choices = data.get(\"choices\")\n if isinstance(choices, list):\n for choice in choices:\n if not isinstance(choice, dict):\n continue\n message = choice.get(\"message\")\n if isinstance(message, dict):\n content = message.get(\"content\")\n if isinstance(content, str):\n candidates.append(content)\n elif isinstance(content, list):\n for item in content:\n if isinstance(item, dict) and isinstance(item.get(\"text\"), str):\n candidates.append(item[\"text\"])\n\n output = data.get(\"output\")\n if isinstance(output, list):\n for item in output:\n if isinstance(item, dict):\n content = item.get(\"content\")\n if isinstance(content, list):\n for part in content:\n if isinstance(part, dict) and isinstance(part.get(\"text\"), str):\n candidates.append(part[\"text\"])\n\n for text in candidates:\n text = text.strip()\n if text:\n return text\n return \"\"\n\n def _extract_360_references(self, data: dict[str, Any], max_results: int) -> list[dict[str, str]]:\n refs: list[dict[str, str]] = []\n possible_lists = [\n data.get(\"references\"),\n data.get(\"refer_search_items\"),\n data.get(\"search_results\"),\n data.get(\"results\"),\n data.get(\"items\"),\n ]\n\n for items in possible_lists:\n if not isinstance(items, list):\n continue\n for item in items:\n if len(refs) >= max_results:\n return refs\n if not isinstance(item, dict):\n continue\n title = str(item.get(\"title\") or item.get(\"name\") or \"\").strip()\n link = str(item.get(\"url\") or item.get(\"link\") or \"\").strip()\n snippet = str(\n item.get(\"summary_ai\")\n or item.get(\"summary\")\n or item.get(\"snippet\")\n or item.get(\"text\")\n or \"\"\n ).strip()\n if title or link or snippet:\n refs.append({\"title\": title, \"link\": link, \"snippet\": snippet})\n\n return refs\n\n def _search_with_serper(self, query: str, max_results: int, api_key: str) -> str:\n payload = json.dumps({\"q\": query, \"num\": max_results}).encode(\"utf-8\")\n request = urllib.request.Request(\n \"https://google.serper.dev/search\",\n data=payload,\n headers={\n \"X-API-KEY\": api_key,\n \"Content-Type\": \"application/json\",\n },\n method=\"POST\",\n )\n data = self._load_json_response(request, \"Serper\")\n\n organic = data.get(\"organic\", [])[:max_results]\n results = [\n {\n \"title\": item.get(\"title\", \"\"),\n \"link\": item.get(\"link\", \"\"),\n \"snippet\": item.get(\"snippet\", \"\"),\n }\n for item in organic\n ]\n return json.dumps(\n {\n \"query\": query,\n \"provider\": \"serper\",\n \"answer_box\": data.get(\"answerBox\"),\n \"knowledge_graph\": data.get(\"knowledgeGraph\"),\n \"results\": results,\n },\n ensure_ascii=False,\n )\n\n def _search_with_duckduckgo(self, query: str, max_results: int) -> str:\n url = \"https://api.duckduckgo.com/?\" + urllib.parse.urlencode(\n {\n \"q\": query,\n \"format\": \"json\",\n \"no_html\": \"1\",\n \"skip_disambig\": \"0\",\n }\n )\n request = urllib.request.Request(\n url,\n headers={\"User-Agent\": \"Mozilla/5.0\"},\n method=\"GET\",\n )\n data = self._load_json_response(request, \"DuckDuckGo\")\n\n results: list[dict[str, str]] = []\n abstract = str(data.get(\"AbstractText\", \"\")).strip()\n abstract_url = str(data.get(\"AbstractURL\", \"\")).strip()\n heading = str(data.get(\"Heading\", \"\")).strip()\n if abstract:\n results.append(\n {\n \"title\": heading or query,\n \"link\": abstract_url,\n \"snippet\": abstract,\n }\n )\n\n def collect_topics(items: list[Any]) -> None:\n for item in items:\n if len(results) >= max_results:\n return\n if isinstance(item, dict) and \"Topics\" in item:\n collect_topics(item.get(\"Topics\", []))\n continue\n if not isinstance(item, dict):\n continue\n text = str(item.get(\"Text\", \"\")).strip()\n link = str(item.get(\"FirstURL\", \"\")).strip()\n if text:\n title = text.split(\" - \", 1)[0]\n results.append({\"title\": title, \"link\": link, \"snippet\": text})\n\n collect_topics(data.get(\"RelatedTopics\", []))\n\n if not results:\n raise ToolExecutionError(\"No usable search results found\")\n\n return json.dumps(\n {\n \"query\": query,\n \"provider\": \"duckduckgo_instant_answer\",\n \"results\": results[:max_results],\n },\n ensure_ascii=False,\n )\n\n def _load_json_response(self, request: urllib.request.Request, provider_label: str) -> dict[str, Any]:\n try:\n with urllib.request.urlopen(request, timeout=20) as response:\n return json.loads(response.read().decode(\"utf-8\"))\n except urllib.error.HTTPError as exc:\n body = \"\"\n try:\n body = exc.read().decode(\"utf-8\", errors=\"ignore\")\n except Exception:\n pass\n raise ToolExecutionError(f\"{provider_label} HTTP {exc.code}: {body[:200]}\") from exc\n except urllib.error.URLError as exc:\n raise ToolExecutionError(f\"{provider_label} network error: {exc.reason}\") from exc\n except json.JSONDecodeError as exc:\n raise ToolExecutionError(f\"{provider_label} returned invalid JSON: {exc}\") from exc\n\n def _get_weather(self, args: dict[str, Any]) -> str:\n location = str(args[\"location\"]).strip()\n if not location:\n raise ToolExecutionError(\"location cannot be empty\")\n\n geocode_url = (\n \"https://geocoding-api.open-meteo.com/v1/search?\"\n + urllib.parse.urlencode({\"name\": location, \"count\": 1, \"language\": \"zh\", \"format\": \"json\"})\n )\n geocode_req = urllib.request.Request(geocode_url, method=\"GET\", headers={\"User-Agent\": \"Mozilla/5.0\"})\n geo = self._load_json_response(geocode_req, \"open-meteo geocoding\")\n results = geo.get(\"results\")\n if not isinstance(results, list) or not results:\n raise ToolExecutionError(f\"Location not found: {location}\")\n\n place = results[0]\n lat = place.get(\"latitude\")\n lon = place.get(\"longitude\")\n if lat is None or lon is None:\n raise ToolExecutionError(f\"Location missing coordinates: {location}\")\n\n weather_params = {\n \"latitude\": lat,\n \"longitude\": lon,\n \"current\": \"temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m\",\n \"daily\": \"weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum\",\n \"timezone\": \"auto\",\n \"forecast_days\": 3,\n }\n weather_url = \"https://api.open-meteo.com/v1/forecast?\" + urllib.parse.urlencode(weather_params)\n weather_req = urllib.request.Request(weather_url, method=\"GET\", headers={\"User-Agent\": \"Mozilla/5.0\"})\n data = self._load_json_response(weather_req, \"open-meteo weather\")\n\n current = data.get(\"current\", {})\n daily = data.get(\"daily\", {})\n dates = daily.get(\"time\", []) or []\n max_t = daily.get(\"temperature_2m_max\", []) or []\n min_t = daily.get(\"temperature_2m_min\", []) or []\n rain = daily.get(\"precipitation_sum\", []) or []\n d_code = daily.get(\"weather_code\", []) or []\n\n forecast = []\n for i in range(min(3, len(dates))):\n forecast.append(\n {\n \"date\": dates[i],\n \"weather\": self._weather_code_to_text(d_code[i] if i < len(d_code) else None),\n \"temp_max_c\": max_t[i] if i < len(max_t) else None,\n \"temp_min_c\": min_t[i] if i < len(min_t) else None,\n \"precipitation_mm\": rain[i] if i < len(rain) else None,\n }\n )\n\n return json.dumps(\n {\n \"query_location\": location,\n \"resolved_location\": {\n \"name\": place.get(\"name\"),\n \"country\": place.get(\"country\"),\n \"admin1\": place.get(\"admin1\"),\n \"latitude\": lat,\n \"longitude\": lon,\n },\n \"current\": {\n \"temperature_c\": current.get(\"temperature_2m\"),\n \"feels_like_c\": current.get(\"apparent_temperature\"),\n \"humidity\": current.get(\"relative_humidity_2m\"),\n \"wind_speed_kmh\": current.get(\"wind_speed_10m\"),\n \"precipitation_mm\": current.get(\"precipitation\"),\n \"weather\": self._weather_code_to_text(current.get(\"weather_code\")),\n },\n \"forecast\": forecast,\n },\n ensure_ascii=False,\n )\n\n def _weather_code_to_text(self, code: Any) -> str:\n mapping = {\n 0: \"晴朗\",\n 1: \"少云\",\n 2: \"多云\",\n 3: \"阴天\",\n 45: \"雾\",\n 48: \"冻雾\",\n 51: \"小毛毛雨\",\n 53: \"毛毛雨\",\n 55: \"强毛毛雨\",\n 61: \"小雨\",\n 63: \"中雨\",\n 65: \"大雨\",\n 71: \"小雪\",\n 73: \"中雪\",\n 75: \"大雪\",\n 80: \"阵雨\",\n 81: \"较强阵雨\",\n 82: \"强阵雨\",\n 95: \"雷暴\",\n }\n if isinstance(code, (int, float)):\n return mapping.get(int(code), f\"未知天气代码({int(code)})\")\n return \"未知\"\n"}}}}