From 2f33c34b5c83db4ca9b85a11e21de0ca4fac35c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=82=A4=E3=82=AB=E3=82=AF?= <1259085392z@gmail.com> Date: Tue, 28 Apr 2026 21:10:19 +0900 Subject: [PATCH 1/7] fix: protect desktop plugin installs with core lock (#7872) --- astrbot/core/utils/core_constraints.py | 10 ++- astrbot/core/utils/desktop_core_lock.py | 108 ++++++++++++++++++++++++ astrbot/core/utils/pip_installer.py | 7 ++ tests/test_pip_installer.py | 96 +++++++++++++++++++++ 4 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 astrbot/core/utils/desktop_core_lock.py 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/tests/test_pip_installer.py b/tests/test_pip_installer.py index bfddf60e1c..266d9195b0 100644 --- a/tests/test_pip_installer.py +++ b/tests/test_pip_installer.py @@ -1,6 +1,8 @@ import asyncio +import json import ntpath import threading +from pathlib import Path from unittest.mock import AsyncMock import pytest @@ -1061,6 +1063,100 @@ def test_core_constraints_file_propagates_inner_conflict_without_fake_warning( assert warning_logs == [] +@pytest.mark.asyncio +async def test_install_adds_desktop_core_lock_constraints_for_packaged_runtime( + monkeypatch, tmp_path +): + monkeypatch.setenv("ASTRBOT_DESKTOP_CLIENT", "1") + monkeypatch.delattr("sys.frozen", raising=False) + + lock_path = tmp_path / "runtime-core-lock.json" + lock_path.write_text( + json.dumps( + { + "version": 1, + "distributions": [ + { + "name": "desktop-only-core", + "version": "9.9.9", + "top_level_modules": ["desktop_only_core"], + } + ], + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("ASTRBOT_DESKTOP_CORE_LOCK_PATH", str(lock_path)) + + site_packages_path = tmp_path / "site-packages" + captured_constraints = [] + + async def capture_pip_args(self, args): + del self + constraints_path = args[args.index("-c") + 1] + captured_constraints.append(Path(constraints_path).read_text(encoding="utf-8")) + return 0 + + monkeypatch.setattr(PipInstaller, "_run_pip_in_process", capture_pip_args) + monkeypatch.setattr( + "astrbot.core.utils.pip_installer.get_astrbot_site_packages_path", + lambda: str(site_packages_path), + ) + monkeypatch.setattr( + "astrbot.core.utils.pip_installer._ensure_plugin_dependencies_preferred", + lambda path, requirements: None, + ) + + installer = PipInstaller("") + await installer.install(package_name="Cua") + + assert captured_constraints + assert "desktop-only-core==9.9.9" in captured_constraints[0] + + +def test_ensure_plugin_dependencies_preferred_skips_desktop_core_lock_modules( + monkeypatch, tmp_path +): + monkeypatch.setenv("ASTRBOT_DESKTOP_CLIENT", "1") + lock_path = tmp_path / "runtime-core-lock.json" + lock_path.write_text( + json.dumps( + { + "version": 1, + "distributions": [ + { + "name": "openai", + "version": "2.32.0", + "top_level_modules": ["openai"], + } + ], + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("ASTRBOT_DESKTOP_CORE_LOCK_PATH", str(lock_path)) + + preferred_calls = [] + + monkeypatch.setattr( + pip_installer_module, + "_collect_candidate_modules", + lambda requirements, site_packages_path: {"openai", "cua_agent"}, + ) + monkeypatch.setattr( + pip_installer_module, + "_ensure_preferred_modules", + lambda modules, site_packages_path: preferred_calls.append(modules), + ) + + pip_installer_module._ensure_plugin_dependencies_preferred( + str(tmp_path / "site-packages"), + {"Cua"}, + ) + + assert preferred_calls == [{"cua_agent"}] + + def test_iter_requirement_lines_expands_nested_requirement_files(tmp_path): base_requirements = tmp_path / "base.txt" base_requirements.write_text("demo-package==1.0\n", encoding="utf-8") From ac7f6aa60d5165d60da64ed7499b893644580019 Mon Sep 17 00:00:00 2001 From: Weilong Liao <37870767+Soulter@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:25:54 +0800 Subject: [PATCH 2/7] feat(shell): add background command execution with output redirection and timeout support (#7835) * feat(shell): add background command execution with output redirection and timeout support * feat(shell): update timeout parameter to be optional in shell execution methods * feat(shell): set default timeout for shell execution to 10,000,000 milliseconds * feat(shell): set default timeout to 300s for shell execution * feat(shell): reorder timeout parameter in ExecuteShellTool configuration * feat(shell): implement background command execution with detached shell command support Co-authored-by: Copilot * test(shell): remove obsolete test for background shell command output redirection * fix: reorder import statements in shell.py for consistency * fix: wrap command in parentheses for background output redirection --------- Co-authored-by: Copilot --- astrbot/core/computer/booters/local.py | 4 +- .../core/computer/booters/shell_background.py | 18 ++++ astrbot/core/computer/booters/shipyard.py | 90 ++++++++++++++++++- astrbot/core/computer/booters/shipyard_neo.py | 13 ++- astrbot/core/computer/olayer/shell.py | 2 +- astrbot/core/tools/computer_tools/shell.py | 58 +++++++++++- 6 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 astrbot/core/computer/booters/shell_background.py 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/tools/computer_tools/shell.py b/astrbot/core/tools/computer_tools/shell.py index cdefe97a0e..a6d44c2214 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"): @@ -69,6 +105,18 @@ async def call( current_workspace_root.mkdir(parents=True, exist_ok=True) cwd = str(current_workspace_root) + stdout_file: str | None = None + if 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, + ) + env = dict(env or {}) effective_background = background and not _is_self_detached_command(command) result = await sb.shell.exec( @@ -76,7 +124,13 @@ async def call( 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__ From 66d620dab56f46e12cbae40c3ce0fe53fecc16f0 Mon Sep 17 00:00:00 2001 From: daniel5u Date: Tue, 28 Apr 2026 23:48:09 +0800 Subject: [PATCH 3/7] fix: merge anthropic parallel tool results (#7875) --- .../core/provider/sources/anthropic_source.py | 41 ++-- tests/test_anthropic_kimi_code_provider.py | 178 ++++++++++++++++++ 2 files changed, 208 insertions(+), 11 deletions(-) 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/tests/test_anthropic_kimi_code_provider.py b/tests/test_anthropic_kimi_code_provider.py index a46953f22c..59ab8645d9 100644 --- a/tests/test_anthropic_kimi_code_provider.py +++ b/tests/test_anthropic_kimi_code_provider.py @@ -93,3 +93,181 @@ def test_anthropic_empty_output_raises_empty_model_output_error(): completion_id="msg_empty", stop_reason="end_turn", ) + + +def _make_anthropic_provider_for_payload_tests() -> anthropic_source.ProviderAnthropic: + return anthropic_source.ProviderAnthropic( + provider_config={"model": "claude-test"}, + provider_settings={}, + use_api_key=False, + ) + + +def test_prepare_payload_merges_consecutive_tool_results_into_single_user_message(): + provider = _make_anthropic_provider_for_payload_tests() + + _, new_messages = provider._prepare_payload( + [ + { + "role": "assistant", + "content": [{"type": "text", "text": "Reading files"}], + "tool_calls": [ + { + "type": "function", + "id": "call_00", + "function": { + "name": "astrbot_file_read_tool", + "arguments": '{"path": "/tmp/one.txt"}', + }, + }, + { + "type": "function", + "id": "call_01", + "function": { + "name": "astrbot_file_read_tool", + "arguments": '{"path": "/tmp/two.txt"}', + }, + }, + ], + }, + { + "role": "tool", + "tool_call_id": "call_00", + "content": "one", + }, + { + "role": "tool", + "tool_call_id": "call_01", + "content": "two", + }, + ] + ) + + assert len(new_messages) == 2 + assert new_messages[1]["role"] == "user" + assert new_messages[1]["content"] == [ + {"type": "tool_result", "tool_use_id": "call_00", "content": "one"}, + {"type": "tool_result", "tool_use_id": "call_01", "content": "two"}, + ] + + +def test_prepare_payload_keeps_single_tool_result_as_single_user_message(): + provider = _make_anthropic_provider_for_payload_tests() + + _, new_messages = provider._prepare_payload( + [ + { + "role": "assistant", + "content": [{"type": "text", "text": "Reading file"}], + "tool_calls": [ + { + "type": "function", + "id": "call_00", + "function": { + "name": "astrbot_file_read_tool", + "arguments": '{"path": "/tmp/one.txt"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_00", + "content": "one", + }, + ] + ) + + assert len(new_messages) == 2 + assert new_messages[1] == { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "call_00", "content": "one"} + ], + } + + +def test_prepare_payload_does_not_merge_non_consecutive_tool_results(): + provider = _make_anthropic_provider_for_payload_tests() + + _, new_messages = provider._prepare_payload( + [ + { + "role": "assistant", + "content": [{"type": "text", "text": "First tool"}], + "tool_calls": [ + { + "type": "function", + "id": "call_00", + "function": { + "name": "astrbot_file_read_tool", + "arguments": '{"path": "/tmp/one.txt"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_00", + "content": "one", + }, + { + "role": "assistant", + "content": [{"type": "text", "text": "Second tool"}], + "tool_calls": [ + { + "type": "function", + "id": "call_01", + "function": { + "name": "astrbot_file_read_tool", + "arguments": '{"path": "/tmp/two.txt"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_01", + "content": "two", + }, + ] + ) + + assert new_messages == [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "First tool"}, + { + "type": "tool_use", + "name": "astrbot_file_read_tool", + "input": {"path": "/tmp/one.txt"}, + "id": "call_00", + }, + ], + }, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "call_00", "content": "one"} + ], + }, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "Second tool"}, + { + "type": "tool_use", + "name": "astrbot_file_read_tool", + "input": {"path": "/tmp/two.txt"}, + "id": "call_01", + }, + ], + }, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "call_01", "content": "two"} + ], + }, + ] From 962c299c2d79f73f717eff1a8aff0f1323da3b90 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 28 Apr 2026 23:55:24 +0800 Subject: [PATCH 4/7] feat(shell): enhance exec method to support timeout parameter and improve background command handling --- astrbot/core/tools/computer_tools/shell.py | 7 ++++--- tests/unit/test_func_tool_manager.py | 24 ++++++++++++++++------ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/astrbot/core/tools/computer_tools/shell.py b/astrbot/core/tools/computer_tools/shell.py index a6d44c2214..1e1acfbf9a 100644 --- a/astrbot/core/tools/computer_tools/shell.py +++ b/astrbot/core/tools/computer_tools/shell.py @@ -105,8 +105,11 @@ async def call( current_workspace_root.mkdir(parents=True, exist_ok=True) cwd = str(current_workspace_root) + env = dict(env or {}) + effective_background = background and not _is_self_detached_command(command) + stdout_file: str | None = None - if background: + if effective_background: local_runtime = is_local_runtime(context) stdout_file = _build_background_output_path( local_runtime=local_runtime, @@ -117,8 +120,6 @@ async def call( local_runtime=local_runtime, ) - env = dict(env or {}) - effective_background = background and not _is_self_detached_command(command) result = await sb.shell.exec( command, cwd=cwd, diff --git a/tests/unit/test_func_tool_manager.py b/tests/unit/test_func_tool_manager.py index 4eae43b5ce..d53ed3296f 100644 --- a/tests/unit/test_func_tool_manager.py +++ b/tests/unit/test_func_tool_manager.py @@ -56,7 +56,9 @@ async def test_execute_shell_defaults_to_foreground(monkeypatch): calls = [] class FakeShell: - async def exec(self, command, cwd=None, background=False, env=None): + async def exec( + self, command, cwd=None, background=False, env=None, timeout=None + ): calls.append({"command": command, "background": background}) return {"success": True, "stdout": "", "stderr": "", "exit_code": 0} @@ -98,7 +100,9 @@ async def test_execute_shell_uses_fresh_default_env_per_call(monkeypatch): calls = [] class FakeShell: - async def exec(self, command, cwd=None, background=False, env=None): + async def exec( + self, command, cwd=None, background=False, env=None, timeout=None + ): env["MUTATED_BY_FAKE_SHELL"] = command calls.append(env) return {"success": True, "stdout": "", "stderr": "", "exit_code": 0} @@ -142,7 +146,9 @@ async def test_execute_shell_copies_user_env_before_execution(monkeypatch): calls = [] class FakeShell: - async def exec(self, command, cwd=None, background=False, env=None): + async def exec( + self, command, cwd=None, background=False, env=None, timeout=None + ): env["MUTATED_BY_FAKE_SHELL"] = command calls.append(env) return {"success": True, "stdout": "", "stderr": "", "exit_code": 0} @@ -186,7 +192,9 @@ async def test_execute_shell_avoids_double_background_for_detached_commands( calls = [] class FakeShell: - async def exec(self, command, cwd=None, background=False, env=None): + async def exec( + self, command, cwd=None, background=False, env=None, timeout=None + ): calls.append({"command": command, "background": background}) return {"success": True, "stdout": "", "stderr": "", "exit_code": 0} @@ -229,7 +237,9 @@ async def test_execute_shell_recognizes_commented_background_command(monkeypatch calls = [] class FakeShell: - async def exec(self, command, cwd=None, background=False, env=None): + async def exec( + self, command, cwd=None, background=False, env=None, timeout=None + ): calls.append({"command": command, "background": background}) return {"success": True, "stdout": "", "stderr": "", "exit_code": 0} @@ -292,7 +302,9 @@ def __str__(self): return "" class FakeShell: - async def exec(self, command, cwd=None, background=False, env=None): + async def exec( + self, command, cwd=None, background=False, env=None, timeout=None + ): raise BlankError() class FakeBooter: From 98b05b7e89d3cefefe6494abd5f93578cc48b5ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AF=97=E6=B5=93?= Date: Tue, 28 Apr 2026 23:55:46 +0800 Subject: [PATCH 5/7] fix(provider): persist model enable toggle (#7865) * fix(provider): persist model enable toggle Fixes AstrBotDevs/AstrBot#7863 * fix(provider): wait for model toggle refresh --- .../provider/ProviderChatCompletionPanel.vue | 2 ++ .../provider/ProviderModelsPanel.vue | 10 ++++-- .../src/composables/useProviderSources.ts | 32 ++++++++++++++++++- 3 files changed, 41 insertions(+), 3 deletions(-) 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)