diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index d96a4c92cb..968426b8b4 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -1293,7 +1293,7 @@ async def _resolve_tool_exec( model=self.req.model, session_id=self.req.session_id, extra_user_content_parts=self.req.extra_user_content_parts, - tool_choice="required", + # tool_choice="required", abort_signal=self._abort_signal, ) if requery_resp: @@ -1319,7 +1319,7 @@ async def _resolve_tool_exec( model=self.req.model, session_id=self.req.session_id, extra_user_content_parts=self.req.extra_user_content_parts, - tool_choice="required", + # tool_choice="required", abort_signal=self._abort_signal, ) if repair_resp: diff --git a/astrbot/core/computer/booters/local.py b/astrbot/core/computer/booters/local.py index 44122361d6..a34d700ada 100644 --- a/astrbot/core/computer/booters/local.py +++ b/astrbot/core/computer/booters/local.py @@ -90,7 +90,7 @@ async def exec( command: str, cwd: str | None = None, env: dict[str, str] | None = None, - timeout: int | None = 30, + timeout: int | None = 300, shell: bool = True, background: bool = False, ) -> dict[str, Any]: @@ -123,7 +123,7 @@ def _run() -> dict[str, Any]: shell=shell, cwd=working_dir, env=run_env, - timeout=timeout, + timeout=timeout or 300, capture_output=True, ) return { diff --git a/astrbot/core/computer/booters/shell_background.py b/astrbot/core/computer/booters/shell_background.py new file mode 100644 index 0000000000..6fe94c133a --- /dev/null +++ b/astrbot/core/computer/booters/shell_background.py @@ -0,0 +1,18 @@ +import shlex + +_BACKGROUND_SPAWN_SCRIPT = ( + "import subprocess, sys; " + "p = subprocess.Popen(" + "['bash', '-lc', sys.argv[1]], " + "stdin=subprocess.DEVNULL, " + "stdout=subprocess.DEVNULL, " + "stderr=subprocess.DEVNULL, " + "start_new_session=True, " + "close_fds=True" + "); " + "print(p.pid)" +) + + +def build_detached_shell_command(command: str) -> str: + return f"python3 -c {shlex.quote(_BACKGROUND_SPAWN_SCRIPT)} {shlex.quote(command)}" diff --git a/astrbot/core/computer/booters/shipyard.py b/astrbot/core/computer/booters/shipyard.py index bed1b06531..a8375544da 100644 --- a/astrbot/core/computer/booters/shipyard.py +++ b/astrbot/core/computer/booters/shipyard.py @@ -1,5 +1,6 @@ from __future__ import annotations +import shlex from typing import Any from shipyard import FileSystemComponent as ShipyardFileSystemComponent @@ -9,9 +10,93 @@ from ..olayer import FileSystemComponent, PythonComponent, ShellComponent from .base import ComputerBooter +from .shell_background import build_detached_shell_command from .shipyard_search_file_util import search_files_via_shell +def _maybe_model_dump(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if hasattr(value, "model_dump"): + dumped = value.model_dump() + if isinstance(dumped, dict): + return dumped + return {} + + +class ShipyardShellWrapper: + def __init__(self, _shipyard_shell: ShellComponent): + self._shell = _shipyard_shell + + async def exec( + self, + command: str, + cwd: str | None = None, + env: dict[str, str] | None = None, + timeout: int | None = 300, + shell: bool = True, + background: bool = False, + ) -> dict[str, Any]: + if not shell: + return { + "stdout": "", + "stderr": "error: only shell mode is supported in shipyard booter.", + "exit_code": 2, + "success": False, + } + + run_command = command + if env: + env_prefix = " ".join( + f"{k}={shlex.quote(str(v))}" for k, v in sorted(env.items()) + ) + run_command = f"{env_prefix} {run_command}" + + if background: + run_command = build_detached_shell_command(run_command) + + result = await self._shell.exec( + run_command, + timeout=timeout or 300, + cwd=cwd, + ) + payload = _maybe_model_dump(result) + + stdout = payload.get("output", payload.get("stdout", "")) or "" + stderr = payload.get("error", payload.get("stderr", "")) or "" + exit_code = payload.get("exit_code") + if background: + pid: int | None = None + try: + pid = int(str(stdout).strip().splitlines()[-1]) + except Exception: + pid = None + return { + "pid": pid, + "stdout": ( + f"Command is running in the background. pid={pid}" + if pid is not None + else "Command was submitted in the background." + ), + "stderr": stderr, + "exit_code": exit_code, + "success": bool(payload.get("success", not stderr)), + "execution_id": payload.get("execution_id"), + "execution_time_ms": payload.get("execution_time_ms"), + "command": payload.get("command"), + } + + return { + "stdout": stdout, + "stderr": stderr, + "exit_code": exit_code, + "success": bool(payload.get("success", not stderr)), + "execution_id": payload.get("execution_id"), + "execution_time_ms": payload.get("execution_time_ms"), + "command": payload.get("command"), + } + + class ShipyardFileSystemWrapper: def __init__( self, _shipyard_fs: ShipyardFileSystemComponent, _shipyard_shell: ShellComponent @@ -107,7 +192,8 @@ async def boot(self, session_id: str) -> None: ) logger.info(f"Got sandbox ship: {ship.id} for session: {session_id}") self._ship = ship - self._fs = ShipyardFileSystemWrapper(self._ship.fs, self._ship.shell) + self._shell = ShipyardShellWrapper(self._ship.shell) + self._fs = ShipyardFileSystemWrapper(self._ship.fs, self._shell) async def shutdown(self) -> None: logger.info("[Computer] Shipyard booter shutdown.") @@ -122,7 +208,7 @@ def python(self) -> PythonComponent: @property def shell(self) -> ShellComponent: - return self._ship.shell + return self._shell async def upload_file(self, path: str, file_name: str) -> dict: """Upload file to sandbox""" diff --git a/astrbot/core/computer/booters/shipyard_neo.py b/astrbot/core/computer/booters/shipyard_neo.py index f3bb7e7b53..cec3c3a366 100644 --- a/astrbot/core/computer/booters/shipyard_neo.py +++ b/astrbot/core/computer/booters/shipyard_neo.py @@ -13,6 +13,7 @@ ShellComponent, ) from .base import ComputerBooter +from .shell_background import build_detached_shell_command from .shipyard_search_file_util import search_files_via_shell try: @@ -96,7 +97,7 @@ async def exec( command: str, cwd: str | None = None, env: dict[str, str] | None = None, - timeout: int | None = 30, + timeout: int | None = 300, shell: bool = True, background: bool = False, ) -> dict[str, Any]: @@ -116,11 +117,11 @@ async def exec( run_command = f"{env_prefix} {run_command}" if background: - run_command = f"nohup sh -lc {shlex.quote(run_command)} >/tmp/astrbot_bg.log 2>&1 & echo $!" + run_command = build_detached_shell_command(run_command) result = await self._sandbox.shell.exec( run_command, - timeout=timeout or 30, + timeout=timeout or 300, cwd=cwd, ) payload = _maybe_model_dump(result) @@ -136,7 +137,11 @@ async def exec( pid = None return { "pid": pid, - "stdout": stdout, + "stdout": ( + f"Command is running in the background. pid={pid}" + if pid is not None + else "Command was submitted in the background." + ), "stderr": stderr, "exit_code": exit_code, "success": bool(payload.get("success", not stderr)), diff --git a/astrbot/core/computer/olayer/shell.py b/astrbot/core/computer/olayer/shell.py index df2263b65a..aef1fd3b6a 100644 --- a/astrbot/core/computer/olayer/shell.py +++ b/astrbot/core/computer/olayer/shell.py @@ -13,7 +13,7 @@ async def exec( command: str, cwd: str | None = None, env: dict[str, str] | None = None, - timeout: int | None = 30, + timeout: int | None = 300, shell: bool = True, background: bool = False, ) -> dict[str, Any]: diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 87e1b0284a..d7849fcb9b 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -195,18 +195,37 @@ def _prepare_payload(self, messages: list[dict]): }, ) elif message["role"] == "tool": - new_messages.append( - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": message["tool_call_id"], - "content": message["content"] or "", - }, - ], - }, + tool_result_block = { + "type": "tool_result", + "tool_use_id": message["tool_call_id"], + "content": message["content"] or "", + } + last_message = new_messages[-1] if new_messages else None + last_content = ( + last_message.get("content") + if isinstance(last_message, dict) + else None + ) + can_append_to_previous_tool_results = ( + last_message is not None + and last_message.get("role") == "user" + and isinstance(last_content, list) + and len(last_content) > 0 + and all( + isinstance(block, dict) and block.get("type") == "tool_result" + for block in last_content + ) ) + + if can_append_to_previous_tool_results: + last_content.append(tool_result_block) + else: + new_messages.append( + { + "role": "user", + "content": [tool_result_block], + }, + ) elif message["role"] == "user": if isinstance(message.get("content"), list): converted_content = [] diff --git a/astrbot/core/tools/computer_tools/shell.py b/astrbot/core/tools/computer_tools/shell.py index cdefe97a0e..1e1acfbf9a 100644 --- a/astrbot/core/tools/computer_tools/shell.py +++ b/astrbot/core/tools/computer_tools/shell.py @@ -1,6 +1,9 @@ import json +import os import shlex +import uuid from dataclasses import dataclass, field +from pathlib import Path from typing import Any from astrbot.api import FunctionTool @@ -8,6 +11,7 @@ from astrbot.core.agent.tool import ToolExecResult from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.computer.computer_client import get_booter +from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path from ..registry import builtin_tool from .util import check_admin_permission, is_local_runtime, workspace_root @@ -17,6 +21,32 @@ } +def _quote_redirect_path(path: str, *, local_runtime: bool) -> str: + if local_runtime and os.name == "nt": + escaped_path = path.replace('"', '""') + else: + escaped_path = path.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped_path}"' + + +def _build_background_output_path(*, local_runtime: bool) -> str: + file_name = f"astrbot_shell_stdout_{uuid.uuid4().hex[:8]}.log" + if local_runtime: + output_dir = Path(get_astrbot_system_tmp_path()) / "shell" + output_dir.mkdir(parents=True, exist_ok=True) + return str((output_dir / file_name).resolve(strict=False)) + return f"/tmp/{file_name}" + + +def _redirect_background_stdout_command( + command: str, + *, + output_path: str, + local_runtime: bool, +) -> str: + return f"({command}) > {_quote_redirect_path(output_path, local_runtime=local_runtime)} 2>&1" + + @builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG) @dataclass class ExecuteShellTool(FunctionTool): @@ -32,12 +62,17 @@ class ExecuteShellTool(FunctionTool): }, "background": { "type": "boolean", - "description": "Whether to run the command in the background.", + "description": "Run the command in the background. Use the file read tool to read the output later. For long running commands, using this option.", "default": False, }, + "timeout": { + "type": "integer", + "description": "Optional timeout in seconds for the command execution.", + "default": 300, + }, "env": { "type": "object", - "description": "Optional environment variables to set for the file creation process.", + "description": "Optional environment variables to set.", "additionalProperties": {"type": "string"}, "default": {}, }, @@ -51,6 +86,7 @@ async def call( context: ContextWrapper[AstrAgentContext], command: str, background: bool = False, + timeout: int | None = 300, env: dict[str, Any] | None = None, ) -> ToolExecResult: if permission_error := check_admin_permission(context, "Shell execution"): @@ -71,12 +107,31 @@ async def call( env = dict(env or {}) effective_background = background and not _is_self_detached_command(command) + + stdout_file: str | None = None + if effective_background: + local_runtime = is_local_runtime(context) + stdout_file = _build_background_output_path( + local_runtime=local_runtime, + ) + command = _redirect_background_stdout_command( + command, + output_path=stdout_file, + local_runtime=local_runtime, + ) + result = await sb.shell.exec( command, cwd=cwd, background=effective_background, env=env, + timeout=timeout or 300, ) + if stdout_file: + result["stdout"] = ( + f"Command is running in the background. stdout/stderr is being " + f"written to `{stdout_file}`. Use astrbot_file_read_tool to read it." + ) return json.dumps(result, ensure_ascii=False) except Exception as e: detail = str(e) or type(e).__name__ diff --git a/astrbot/core/tools/message_tools.py b/astrbot/core/tools/message_tools.py index 31e7ba9041..177461d944 100644 --- a/astrbot/core/tools/message_tools.py +++ b/astrbot/core/tools/message_tools.py @@ -14,6 +14,7 @@ from astrbot.core.computer.computer_client import get_booter from astrbot.core.message.message_event_result import MessageChain from astrbot.core.platform.message_session import MessageSession +from astrbot.core.tools.computer_tools.util import check_admin_permission from astrbot.core.tools.registry import builtin_tool from astrbot.core.utils.astrbot_path import get_astrbot_temp_path @@ -117,7 +118,16 @@ async def _resolve_path_from_sandbox( async def call( self, context: ContextWrapper[AstrAgentContext], **kwargs ) -> ToolExecResult: - session = kwargs.get("session") or context.context.event.unified_msg_origin + # Security: only AstrBot admins can send messages to other sessions. + # Non-admin users are always restricted to their own session. + # See https://github.com/AstrBotDevs/AstrBot/issues/7822 + current_session = context.context.event.unified_msg_origin + session = kwargs.get("session") or current_session + if session != current_session: + if permission_error := check_admin_permission( + context, "Send message to another session" + ): + return permission_error messages = kwargs.get("messages") if not isinstance(messages, list) or not messages: return "error: messages parameter is empty or invalid." diff --git a/astrbot/core/utils/core_constraints.py b/astrbot/core/utils/core_constraints.py index b43f001227..ed353e738b 100644 --- a/astrbot/core/utils/core_constraints.py +++ b/astrbot/core/utils/core_constraints.py @@ -7,6 +7,7 @@ from packaging.requirements import Requirement +from astrbot.core.utils.desktop_core_lock import get_desktop_core_lock_constraints from astrbot.core.utils.requirements_utils import ( canonicalize_distribution_name, collect_installed_distribution_versions, @@ -93,7 +94,14 @@ def __init__(self, core_dist_name: str | None) -> None: @contextlib.contextmanager def constraints_file(self) -> Iterator[str | None]: - constraints = _get_core_constraints(self._core_dist_name) + constraints = tuple( + dict.fromkeys( + ( + *_get_core_constraints(self._core_dist_name), + *get_desktop_core_lock_constraints(), + ) + ) + ) if not constraints: yield None return diff --git a/astrbot/core/utils/desktop_core_lock.py b/astrbot/core/utils/desktop_core_lock.py new file mode 100644 index 0000000000..933c9fb307 --- /dev/null +++ b/astrbot/core/utils/desktop_core_lock.py @@ -0,0 +1,108 @@ +import json +import logging +import os +import re +from functools import lru_cache +from typing import Any + +from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime + +logger = logging.getLogger("astrbot") + +DESKTOP_CORE_LOCK_PATH_ENV = "ASTRBOT_DESKTOP_CORE_LOCK_PATH" + + +def _canonicalize_distribution_name(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).strip("-").lower() + + +def _safe_requirement_pin(name: str, version: str) -> str | None: + if not name or not version: + return None + if any(char.isspace() for char in name) or any(char.isspace() for char in version): + return None + return f"{name}=={version}" + + +def _fallback_module_name(name: str) -> str: + return _canonicalize_distribution_name(name).replace("-", "_") + + +def _iter_distribution_records(data: Any): + if not isinstance(data, dict): + return + distributions = data.get("distributions", []) + if not isinstance(distributions, list): + return + for record in distributions: + if isinstance(record, dict): + yield record + + +@lru_cache(maxsize=8) +def _load_lock_data(lock_path: str) -> dict[str, Any] | None: + try: + with open(lock_path, encoding="utf-8") as file: + data = json.load(file) + except FileNotFoundError: + logger.warning("桌面端核心依赖锁不存在: %s", lock_path) + return None + except Exception as exc: + logger.warning("读取桌面端核心依赖锁失败: %s", exc) + return None + + if not isinstance(data, dict): + logger.warning("桌面端核心依赖锁格式无效: %s", lock_path) + return None + return data + + +def _resolve_lock_data() -> dict[str, Any] | None: + if not is_packaged_desktop_runtime(): + return None + + lock_path = os.environ.get(DESKTOP_CORE_LOCK_PATH_ENV, "").strip() + if not lock_path: + return None + return _load_lock_data(lock_path) + + +def get_desktop_core_lock_constraints() -> tuple[str, ...]: + data = _resolve_lock_data() + if not data: + return () + + constraints: dict[str, str] = {} + for record in _iter_distribution_records(data): + name = record.get("name") + version = record.get("version") + if not isinstance(name, str) or not isinstance(version, str): + continue + + pin = _safe_requirement_pin(name, version) + if not pin: + continue + constraints.setdefault(_canonicalize_distribution_name(name), pin) + + return tuple(constraints[key] for key in sorted(constraints)) + + +def get_desktop_core_lock_modules() -> frozenset[str]: + data = _resolve_lock_data() + if not data: + return frozenset() + + modules: set[str] = set() + for record in _iter_distribution_records(data): + name = record.get("name") + top_level_modules = record.get("top_level_modules", []) + if isinstance(top_level_modules, list): + for module_name in top_level_modules: + if isinstance(module_name, str) and module_name: + modules.add(module_name.split(".", 1)[0]) + if isinstance(name, str): + fallback = _fallback_module_name(name) + if fallback: + modules.add(fallback) + + return frozenset(modules) diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index e5f7138209..fbc1b5a7ec 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -18,6 +18,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path from astrbot.core.utils.core_constraints import CoreConstraintsProvider +from astrbot.core.utils.desktop_core_lock import get_desktop_core_lock_modules from astrbot.core.utils.requirements_utils import ( canonicalize_distribution_name as _canonicalize_distribution_name, ) @@ -811,6 +812,12 @@ def _ensure_plugin_dependencies_preferred( if not candidate_modules: return + locked_modules = get_desktop_core_lock_modules() + if locked_modules: + candidate_modules = candidate_modules.difference(locked_modules) + if not candidate_modules: + return + _ensure_preferred_modules(candidate_modules, target_site_packages) diff --git a/dashboard/src/components/provider/ProviderChatCompletionPanel.vue b/dashboard/src/components/provider/ProviderChatCompletionPanel.vue index 71d053f421..54c48cc634 100644 --- a/dashboard/src/components/provider/ProviderChatCompletionPanel.vue +++ b/dashboard/src/components/provider/ProviderChatCompletionPanel.vue @@ -89,6 +89,7 @@ :supports-tool-call="supportsToolCall" :supports-reasoning="supportsReasoning" :format-context-limit="formatContextLimit" + :saving-providers="savingProviderToggles" :testing-providers="testingProviders" :tm="tm" @fetch-models="fetchAvailableModels" @@ -209,6 +210,7 @@ const { availableModels, loadingModels, savingSource, + savingProviderToggles, testingProviders, isSourceModified, configSchema, diff --git a/dashboard/src/components/provider/ProviderModelsPanel.vue b/dashboard/src/components/provider/ProviderModelsPanel.vue index 3e787f059d..86a673b3f7 100644 --- a/dashboard/src/components/provider/ProviderModelsPanel.vue +++ b/dashboard/src/components/provider/ProviderModelsPanel.vue @@ -84,12 +84,13 @@
@@ -97,7 +98,7 @@ icon="mdi-connection" size="small" variant="text" - :disabled="!entry.provider.enable" + :disabled="!entry.provider.enable || isProviderSaving(entry.provider.id)" :loading="isProviderTesting(entry.provider.id)" @click.stop="emit('test-provider', entry.provider)" > @@ -240,6 +241,10 @@ const props = defineProps({ type: Array, default: () => [] }, + savingProviders: { + type: Array, + default: () => [] + }, tm: { type: Function, required: true @@ -288,6 +293,7 @@ const capabilityIcons = (metadata) => { } const isProviderTesting = (providerId) => props.testingProviders.includes(providerId) +const isProviderSaving = (providerId) => props.savingProviders.includes(providerId)