diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index f0c25a6c8..a1f443bf2 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -12,15 +12,21 @@ jobs: steps: - name: checkout uses: actions/checkout@v6 - - name: nodejs installation + - name: Setup pnpm + uses: pnpm/action-setup@v5.0.0 + with: + version: 10.28.2 + - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: "18" - - name: npm install - run: npm add -D vitepress - working-directory: './docs' # working-directory 指定 shell 命令运行目录 - - name: npm run build - run: npm run docs:build + node-version: "24.13.0" + cache: "pnpm" + cache-dependency-path: docs/pnpm-lock.yaml + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: './docs' + - name: Build docs + run: pnpm run docs:build working-directory: './docs' - name: scp uses: appleboy/scp-action@v1.0.0 diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml index 916f43f97..beb7b8871 100644 --- a/.github/workflows/dashboard_ci.yml +++ b/.github/workflows/dashboard_ci.yml @@ -14,18 +14,22 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + - name: Setup pnpm + uses: pnpm/action-setup@v5.0.0 + with: + version: 10.28.2 + - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: '24.13.0' + cache: "pnpm" + cache-dependency-path: dashboard/pnpm-lock.yaml - - name: npm install, build + - name: Install and Build run: | - cd dashboard - npm install pnpm -g - pnpm install - pnpm i --save-dev @types/markdown-it - pnpm run build + pnpm --dir dashboard install --frozen-lockfile + pnpm --dir dashboard run build - name: Inject Commit SHA id: get_sha diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index b0378532d..4e442d015 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -98,7 +98,7 @@ jobs: echo "EOF" >> $GITHUB_OUTPUT - name: Build and Push Nightly Image - uses: docker/build-push-action@v7.0.0 + uses: docker/build-push-action@v7.1.0 with: context: . platforms: linux/amd64,linux/arm64 @@ -183,7 +183,7 @@ jobs: password: ${{ secrets.GHCR_GITHUB_TOKEN }} - name: Build and Push Release Image - uses: docker/build-push-action@v7.0.0 + uses: docker/build-push-action@v7.1.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index d5faf88a5..c7d6024b6 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Validate PR title - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const title = (context.payload.pull_request.title || "").trim(); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b6ae4d3a..bfe80a115 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ jobs: echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Setup pnpm - uses: pnpm/action-setup@v5.0.0 + uses: pnpm/action-setup@v6.0.0 with: version: 10.28.2 diff --git a/Dockerfile b/Dockerfile index 7bfb00c7e..30977605c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ gnupg \ git \ + ripgrep \ && curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && apt-get clean \ diff --git a/README.md b/README.md index b21f9f035..6774d4305 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,13 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with For users who want to quickly experience AstrBot, are familiar with command-line usage, and can install a `uv` environment on their own, we recommend the `uv` one-click deployment method ⚡️: ```bash -uv tool install astrbot +uv tool install astrbot --python 3.12 astrbot init # Only execute this command for the first time to initialize the environment astrbot run ``` > Requires [uv](https://docs.astral.sh/uv/) to be installed. +> AstrBot requires Python 3.12 or later. The `--python 3.12` option ensures that `uv` creates the tool environment with Python 3.12. > [!NOTE] > For macOS user: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s). @@ -90,7 +91,7 @@ astrbot run Update `astrbot`: ```bash -uv tool upgrade astrbot +uv tool upgrade astrbot --python 3.12 ``` > [!WARNING] @@ -157,11 +158,12 @@ Connect AstrBot to your favorite chat platform. | Discord | Official | | LINE | Official | | Satori | Official | +| KOOK | Official | | Misskey | Official | | Mattermost | Official | | WhatsApp (Coming Soon) | Official | | [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community | -| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community | +| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Community | | [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Community | ## Supported Model Services diff --git a/README_fr.md b/README_fr.md index 8324808e1..c7dbeac3c 100644 --- a/README_fr.md +++ b/README_fr.md @@ -76,12 +76,13 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont familiers avec la ligne de commande et peuvent installer eux-mêmes l'environnement `uv`, nous recommandons la méthode de déploiement en un clic avec `uv` ⚡️ : ```bash -uv tool install astrbot +uv tool install astrbot --python 3.12 astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement astrbot run ``` > [uv](https://docs.astral.sh/uv/) doit être installé. +> AstrBot nécessite Python 3.12 ou une version plus récente. L'option `--python 3.12` garantit que `uv` crée l'environnement tool avec Python 3.12. > [!NOTE] > Pour les utilisateurs macOS : en raison des vérifications de sécurité de macOS, la première exécution de la commande `astrbot` peut prendre plus de temps (environ 10-20s). @@ -89,7 +90,7 @@ astrbot run Mettre à jour `astrbot` : ```bash -uv tool upgrade astrbot +uv tool upgrade astrbot --python 3.12 ``` > [!WARNING] @@ -156,10 +157,12 @@ Connectez AstrBot à vos plateformes de chat préférées. | Discord | Officielle | | LINE | Officielle | | Satori | Officielle | +| KOOK | Officielle | | Misskey | Officielle | +| Mattermost | Officielle | | WhatsApp (Bientôt disponible) | Officielle | | [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Communauté | -| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Communauté | +| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Communauté | | [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Communauté | ## Services de modèles pris en charge diff --git a/README_ja.md b/README_ja.md index 621bbbf6e..1e91a0ebc 100644 --- a/README_ja.md +++ b/README_ja.md @@ -76,12 +76,13 @@ AstrBot は、主要なインスタントメッセージングアプリと統合 AstrBot を素早く試したいユーザーで、コマンドラインに慣れており `uv` 環境を自分でインストールできる場合は、`uv` のワンクリックデプロイをおすすめします ⚡️: ```bash -uv tool install astrbot +uv tool install astrbot --python 3.12 astrbot init # 初回のみ実行して環境を初期化します astrbot run ``` > [uv](https://docs.astral.sh/uv/) のインストールが必要です。 +> AstrBot には Python 3.12 以降が必要です。`--python 3.12` を指定すると、`uv` は Python 3.12 で tool 環境を作成します。 > [!NOTE] > macOS ユーザーの場合:macOS のセキュリティチェックにより、`astrbot` コマンドの初回実行に時間がかかる場合があります(約 10〜20 秒)。 @@ -89,7 +90,7 @@ astrbot run `astrbot` の更新: ```bash -uv tool upgrade astrbot +uv tool upgrade astrbot --python 3.12 ``` > [!WARNING] @@ -156,10 +157,12 @@ AstrBot をよく使うチャットプラットフォームに接続できます | Discord | 公式 | | LINE | 公式 | | Satori | 公式 | +| KOOK | 公式 | | Misskey | 公式 | +| Mattermost | 公式 | | WhatsApp (近日対応予定) | 公式 | | [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | コミュニティ | -| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | コミュニティ | +| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | コミュニティ | | [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | コミュニティ | diff --git a/README_ru.md b/README_ru.md index 742c207bd..a1fb90678 100644 --- a/README_ru.md +++ b/README_ru.md @@ -76,12 +76,13 @@ AstrBot — это универсальная платформа Agent-чатб Для пользователей, которые хотят быстро попробовать AstrBot, знакомы с командной строкой и могут самостоятельно установить окружение `uv`, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️: ```bash -uv tool install astrbot +uv tool install astrbot --python 3.12 astrbot init # Выполните эту команду только при первом запуске для инициализации окружения astrbot run ``` > Требуется установленный [uv](https://docs.astral.sh/uv/). +> Для AstrBot требуется Python 3.12 или новее. Параметр `--python 3.12` гарантирует, что `uv` создаст tool-окружение с Python 3.12. > [!NOTE] > Для пользователей macOS: из-за проверок безопасности macOS первый запуск команды `astrbot` может занять больше времени (около 10-20 секунд). @@ -89,7 +90,7 @@ astrbot run Обновить `astrbot`: ```bash -uv tool upgrade astrbot +uv tool upgrade astrbot --python 3.12 ``` > [!WARNING] @@ -156,10 +157,12 @@ yay -S astrbot-git | Discord | Официальная | | LINE | Официальная | | Satori | Официальная | +| KOOK | Официальная | | Misskey | Официальная | +| Mattermost | Официальная | | WhatsApp (Скоро) | Официальная | | [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Сообщество | -| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Сообщество | +| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Сообщество | | [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Сообщество | ## Поддерживаемые сервисы моделей diff --git a/README_zh-TW.md b/README_zh-TW.md index 478831223..da5f3abf4 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -76,12 +76,13 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主 對於想快速體驗 AstrBot、且熟悉命令列並能自行安裝 `uv` 環境的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️。 ```bash -uv tool install astrbot +uv tool install astrbot --python 3.12 astrbot init # 僅首次執行此命令以初始化環境 astrbot run ``` > 需要安裝 [uv](https://docs.astral.sh/uv/)。 +> AstrBot 需要 Python 3.12 或更高版本。`--python 3.12` 會確保 `uv` 使用 Python 3.12 建立 tool 環境。 > [!NOTE] > 對於 macOS 使用者:由於 macOS 安全性檢查,首次執行 `astrbot` 指令可能需要較長時間(約 10-20 秒)。 @@ -89,7 +90,7 @@ astrbot run 更新 `astrbot`: ```bash -uv tool upgrade astrbot +uv tool upgrade astrbot --python 3.12 ``` > [!WARNING] @@ -156,10 +157,12 @@ yay -S astrbot-git | Discord | 官方維護 | | LINE | 官方維護 | | Satori | 官方維護 | +| KOOK | 官方維護 | | Misskey | 官方維護 | +| Mattermost | 官方維護 | | Whatsapp(即將支援) | 官方維護 | | [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社群維護 | -| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | 社群維護 | +| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | 社群維護 | | [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社群維護 | ## 支援的模型服務 diff --git a/README_zh.md b/README_zh.md index 2bd2c7539..44ffa13a9 100644 --- a/README_zh.md +++ b/README_zh.md @@ -76,7 +76,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、 对于想快速体验 AstrBot、且熟悉命令行并能够自行安装 `uv` 环境的用户,我们推荐使用 `uv` 一键部署方式 ⚡️。 ```bash -uv tool install astrbot +uv tool install astrbot --python 3.12 astrbot init # 仅首次执行此命令以初始化环境 astrbot run # astrbot run --backend-only 仅启动后端服务 @@ -85,6 +85,7 @@ uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev ``` > 需要安装 [uv](https://docs.astral.sh/uv/)。 +> AstrBot 需要 Python 3.12 或更高版本。`--python 3.12` 会确保 `uv` 使用 Python 3.12 创建 tool 环境。 > [!NOTE] > 对于 macOS 用户:由于 macOS 安全检查,首次运行 `astrbot` 命令可能需要较长时间(约 10-20 秒)。 @@ -92,7 +93,7 @@ uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev 更新 `astrbot`: ```bash -uv tool upgrade astrbot +uv tool upgrade astrbot --python 3.12 ``` > [!WARNING] @@ -159,10 +160,12 @@ yay -S astrbot-git | **Discord** | 官方维护 | | **LINE** | 官方维护 | | **Satori** | 官方维护 | +| **KOOK** | 官方维护 | | **Misskey** | 官方维护 | +| **Mattermost** | 官方维护 | | **Whatsapp (将支持)** | 官方维护 | | [**Matrix**](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社区维护 | -| [**KOOK**](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | 社区维护 | +| [**Rocket.Chat**](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | 社区维护 | | [**VoceChat**](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社区维护 | ## 支持的模型提供商 diff --git a/astrbot/api/event/filter/__init__.py b/astrbot/api/event/filter/__init__.py index 71b21a445..38bef6761 100644 --- a/astrbot/api/event/filter/__init__.py +++ b/astrbot/api/event/filter/__init__.py @@ -14,6 +14,8 @@ from astrbot.core.star.register import register_command_group as command_group from astrbot.core.star.register import register_custom_filter as custom_filter from astrbot.core.star.register import register_event_message_type as event_message_type from astrbot.core.star.register import register_llm_tool as llm_tool +from astrbot.core.star.register import register_on_agent_begin as on_agent_begin +from astrbot.core.star.register import register_on_agent_done as on_agent_done from astrbot.core.star.register import register_on_astrbot_loaded as on_astrbot_loaded from astrbot.core.star.register import ( register_on_decorating_result as on_decorating_result, @@ -51,6 +53,8 @@ __all__ = [ "custom_filter", "event_message_type", "llm_tool", + "on_agent_begin", + "on_agent_done", "on_astrbot_loaded", "on_decorating_result", "on_llm_request", diff --git a/astrbot/builtin_stars/astrbot/main.py b/astrbot/builtin_stars/astrbot/main.py index 7a8c39b74..b0b3f411b 100644 --- a/astrbot/builtin_stars/astrbot/main.py +++ b/astrbot/builtin_stars/astrbot/main.py @@ -36,9 +36,9 @@ class Main(star.Star): if self.ltm_enabled(event) and self.ltm and has_image_or_plain: need_active = await self.ltm.need_active_reply(event) - group_icl_enable = self.context.get_config()["provider_ltm_settings"][ - "group_icl_enable" - ] + group_icl_enable = self.context.get_config(umo=event.unified_msg_origin)[ + "provider_ltm_settings" + ]["group_icl_enable"] if group_icl_enable: """记录对话""" try: diff --git a/astrbot/builtin_stars/builtin_commands/commands/__init__.py b/astrbot/builtin_stars/builtin_commands/commands/__init__.py index 46d255965..552ac4a8a 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/__init__.py +++ b/astrbot/builtin_stars/builtin_commands/commands/__init__.py @@ -1,29 +1,15 @@ # Commands module from .admin import AdminCommands -from .alter_cmd import AlterCmdCommands from .conversation import ConversationCommands from .help import HelpCommand -from .llm import LLMCommands -from .persona import PersonaCommands -from .plugin import PluginCommands -from .provider import ProviderCommands from .setunset import SetUnsetCommands from .sid import SIDCommand -from .t2i import T2ICommand -from .tts import TTSCommand __all__ = [ "AdminCommands", - "AlterCmdCommands", "ConversationCommands", "HelpCommand", - "LLMCommands", - "PersonaCommands", - "PluginCommands", - "ProviderCommands", - "SIDCommand", "SetUnsetCommands", - "T2ICommand", - "TTSCommand", + "SIDCommand", ] diff --git a/astrbot/builtin_stars/builtin_commands/commands/help.py b/astrbot/builtin_stars/builtin_commands/commands/help.py index 85858f237..2d9075b7b 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/help.py +++ b/astrbot/builtin_stars/builtin_commands/commands/help.py @@ -30,7 +30,6 @@ class HelpCommand: return [] lines: list[str] = [] - hidden_commands = {"set", "unset", "websearch"} def walk(items: list[dict], indent: int = 0) -> None: for item in items: @@ -47,9 +46,12 @@ class HelpCommand: or item.get("original_command") or item.get("handler_name") ) - if not effective: - continue - if effective in hidden_commands: + if not effective or effective in [ + "set", + "unset", + "help", + "dashboard_update", + ]: continue description = item.get("description") or "" @@ -71,12 +73,13 @@ class HelpCommand: dashboard_version = await get_dashboard_version() command_lines = await self._build_reserved_command_lines() commands_section = ( - "\n".join(command_lines) if command_lines else "暂无启用的内置指令" + "\n".join(command_lines) + if command_lines + else "No enabled built-in commands." ) msg_parts = [ f"AstrBot v{VERSION}(WebUI: {dashboard_version})", - "内置指令:", commands_section, ] if notice: diff --git a/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py b/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py index 2f0b5f25e..f50b75abb 100644 --- a/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py +++ b/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py @@ -426,18 +426,20 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]): ) return messages - def _build_runtime_context(self, thread_id: str) -> dict[str, T.Any]: - runtime_context: dict[str, T.Any] = { + def _build_runtime_configurable(self, thread_id: str) -> dict[str, T.Any]: + runtime_configurable: dict[str, T.Any] = { "thread_id": thread_id, "thinking_enabled": self.thinking_enabled, "is_plan_mode": self.plan_mode, "subagent_enabled": self.subagent_enabled, } if self.subagent_enabled: - runtime_context["max_concurrent_subagents"] = self.max_concurrent_subagents + runtime_configurable["max_concurrent_subagents"] = ( + self.max_concurrent_subagents + ) if self.model_name: - runtime_context["model_name"] = self.model_name - return runtime_context + runtime_configurable["model_name"] = self.model_name + return runtime_configurable def _build_payload( self, @@ -446,16 +448,19 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]): image_urls: list[str], system_prompt: str | None, ) -> dict[str, T.Any]: + runtime_configurable = self._build_runtime_configurable(thread_id) return { "assistant_id": self.assistant_id, "input": { "messages": self._build_messages(prompt, image_urls, system_prompt), }, "stream_mode": ["values", "messages-tuple", "custom"], - # LangGraph 0.6+ prefers context instead of configurable. - "context": self._build_runtime_context(thread_id), + # DeerFlow 2.0 consumes runtime overrides from config.configurable. + # Keep the legacy context mirror for older compat paths. + "context": dict(runtime_configurable), "config": { "recursion_limit": self.recursion_limit, + "configurable": runtime_configurable, }, } diff --git a/astrbot/core/agent/runners/deerflow/deerflow_api_client.py b/astrbot/core/agent/runners/deerflow/deerflow_api_client.py index 5b23dd2e2..8d335cadf 100644 --- a/astrbot/core/agent/runners/deerflow/deerflow_api_client.py +++ b/astrbot/core/agent/runners/deerflow/deerflow_api_client.py @@ -12,6 +12,33 @@ from astrbot.core import logger SSE_MAX_BUFFER_CHARS = 1_048_576 +class DeerFlowAPIError(Exception): + def __init__( + self, + *, + operation: str, + status: int, + body: str, + url: str, + thread_id: str | None = None, + ) -> None: + self.operation = operation + self.status = status + self.body = body + self.url = url + self.thread_id = thread_id + + message = ( + f"DeerFlow {operation} failed: status={status}, url={url}, body={body}" + ) + if thread_id is not None: + message = ( + f"DeerFlow {operation} failed: thread_id={thread_id}, " + f"status={status}, url={url}, body={body}" + ) + super().__init__(message) + + def _normalize_sse_newlines(text: str) -> str: """Normalize CRLF/CR to LF so SSE block splitting works reliably.""" return text.replace("\r\n", "\n").replace("\r", "\n") @@ -154,11 +181,33 @@ class DeerFlowAPIClient: ) as resp: if resp.status not in (200, 201): text = await resp.text() - raise Exception( - f"DeerFlow create thread failed: {resp.status}. {text}", + raise DeerFlowAPIError( + operation="create thread", + status=resp.status, + body=text, + url=url, ) return await resp.json() + async def delete_thread(self, thread_id: str, timeout: float = 20) -> None: + session = self._get_session() + url = f"{self.api_base}/api/threads/{thread_id}" + async with session.delete( + url, + headers=self.headers, + timeout=timeout, + proxy=self.proxy, + ) as resp: + if resp.status not in (200, 202, 204, 404): + text = await resp.text() + raise DeerFlowAPIError( + operation="delete thread", + status=resp.status, + body=text, + url=url, + thread_id=thread_id, + ) + async def stream_run( self, thread_id: str, @@ -203,8 +252,12 @@ class DeerFlowAPIClient: ) as resp: if resp.status != 200: text = await resp.text() - raise Exception( - f"DeerFlow runs/stream request failed: {resp.status}. {text}", + raise DeerFlowAPIError( + operation="runs/stream request", + status=resp.status, + body=text, + url=url, + thread_id=thread_id, ) async for event in _stream_sse(resp): yield event diff --git a/astrbot/core/computer/booters/shipyard_search_file_util.py b/astrbot/core/computer/booters/shipyard_search_file_util.py new file mode 100644 index 000000000..1227244de --- /dev/null +++ b/astrbot/core/computer/booters/shipyard_search_file_util.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import shlex +from typing import Any + +from ..olayer import ShellComponent + +_MAX_SEARCH_LINE_COLUMNS = 1000 + + +def _truncate_long_lines(text: str) -> str: + output_lines: list[str] = [] + for line in text.splitlines(keepends=True): + line_ending = "" + line_body = line + if line.endswith("\r\n"): + line_body = line[:-2] + line_ending = "\r\n" + elif line.endswith("\n") or line.endswith("\r"): + line_body = line[:-1] + line_ending = line[-1] + + if len(line_body) > _MAX_SEARCH_LINE_COLUMNS: + line_body = line_body[:_MAX_SEARCH_LINE_COLUMNS] + + output_lines.append(f"{line_body}{line_ending}") + return "".join(output_lines) + + +def _build_rg_command( + *, + pattern: str, + path: str, + glob: str | None, + after_context: int | None, + before_context: int | None, +) -> list[str]: + command = [ + "rg", + "--color=never", + "-n", + "--max-columns", + str(_MAX_SEARCH_LINE_COLUMNS), + "-e", + pattern, + ] + if glob: + command.extend(["-g", glob]) + if after_context is not None: + command.extend(["-A", str(after_context)]) + if before_context is not None: + command.extend(["-B", str(before_context)]) + command.extend(["--", path]) + return command + + +def _build_grep_command( + *, + pattern: str, + path: str, + glob: str | None, + after_context: int | None, + before_context: int | None, +) -> list[str]: + command = ["grep", "-R", "-H", "-n", "-e", pattern] + if glob: + command.append(f"--include={glob}") + if after_context is not None: + command.extend(["-A", str(after_context)]) + if before_context is not None: + command.extend(["-B", str(before_context)]) + command.extend(["--", path]) + return command + + +def _quote_command(command: list[str]) -> str: + return " ".join(shlex.quote(part) for part in command) + + +def build_search_command( + *, + pattern: str, + path: str, + glob: str | None, + after_context: int | None, + before_context: int | None, +) -> str: + rg_command = _quote_command( + _build_rg_command( + pattern=pattern, + path=path, + glob=glob, + after_context=after_context, + before_context=before_context, + ) + ) + grep_command = _quote_command( + _build_grep_command( + pattern=pattern, + path=path, + glob=glob, + after_context=after_context, + before_context=before_context, + ) + ) + return ( + "if command -v rg >/dev/null 2>&1; then " + f"{rg_command}; " + "elif command -v grep >/dev/null 2>&1; then " + f"{grep_command}; " + "else " + "echo 'Neither rg nor grep is available in the sandbox.' >&2; " + "exit 127; " + "fi" + ) + + +async def search_files_via_shell( + shell: ShellComponent, + *, + pattern: str, + path: str | None = None, + glob: str | None = None, + after_context: int | None = None, + before_context: int | None = None, + timeout: int = 30, +) -> dict[str, Any]: + command = build_search_command( + pattern=pattern, + path=path or ".", + glob=glob, + after_context=after_context, + before_context=before_context, + ) + result = await shell.exec(command, timeout=timeout) + stdout = _truncate_long_lines(str(result.get("stdout", "") or "")) + stderr = str(result.get("stderr", "") or "") + exit_code = result.get("exit_code") + if exit_code in (0, None): + return {"success": True, "content": stdout} + if exit_code == 1: + return {"success": True, "content": ""} + return { + "success": False, + "content": "", + "error": stderr or f"command exited with code {exit_code}", + "exit_code": exit_code, + } diff --git a/astrbot/core/computer/file_read_utils.py b/astrbot/core/computer/file_read_utils.py new file mode 100644 index 000000000..0f4d0811c --- /dev/null +++ b/astrbot/core/computer/file_read_utils.py @@ -0,0 +1,707 @@ +from __future__ import annotations + +import base64 +import hashlib +import io +import json +import zipfile +from asyncio import to_thread +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +import mcp + +from astrbot.core.agent.context.token_counter import EstimateTokenCounter +from astrbot.core.agent.message import Message +from astrbot.core.agent.tool import ToolExecResult +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.core.utils.media_utils import ( + IMAGE_COMPRESS_DEFAULT_MAX_SIZE, + IMAGE_COMPRESS_DEFAULT_OPTIMIZE, + IMAGE_COMPRESS_DEFAULT_QUALITY, + _compress_image_sync, +) + +from .booters.base import ComputerBooter + +_MAX_FILE_READ_BYTES = 128 * 1024 +_MAX_FILE_READ_TOKENS = 25_000 +_MAX_TEXT_FILE_FULL_READ_BYTES = 256 * 1024 +_FILE_SNIFF_BYTES = 512 +_TOKEN_COUNTER = EstimateTokenCounter() +_TEXT_ENCODINGS = ( + "utf-8-sig", + "utf-8", + "gb18030", + "utf-16", + "utf-16-le", + "utf-16-be", + "utf-32", + "utf-32-le", + "utf-32-be", +) +_UTF_BOMS = ( + b"\xef\xbb\xbf", + b"\xff\xfe", + b"\xfe\xff", + b"\xff\xfe\x00\x00", + b"\x00\x00\xfe\xff", +) +_ZIP_MAGIC_PREFIXES = ( + b"PK\x03\x04", + b"PK\x05\x06", + b"PK\x07\x08", +) +_BINARY_MAGIC_PREFIXES = ( + b"%PDF-", + b"\x1f\x8b", + b"7z\xbc\xaf\x27\x1c", + b"Rar!\x1a\x07", + b"\x7fELF", + b"MZ", +) + + +@dataclass(frozen=True) +class FileProbe: + kind: Literal["text", "image", "binary"] + encoding: str | None + mime_type: str | None + size_bytes: int + + +@dataclass(frozen=True) +class ParsedDocument: + kind: Literal["docx", "pdf"] + file_bytes: bytes + text: str + + +def _build_probe_script(path: str) -> str: + return f""" +import base64 +import json +from pathlib import Path + +path = Path({path!r}) +with path.open("rb") as file_obj: + sample = file_obj.read({_FILE_SNIFF_BYTES}) +print( + json.dumps( + {{ + "size_bytes": path.stat().st_size, + "sample_b64": base64.b64encode(sample).decode("ascii"), + }} + ) +) +""".strip() + + +def _build_text_read_script( + path: str, + *, + encoding: str, + offset: int | None, + limit: int | None, +) -> str: + start_expr = "0" if offset is None else str(offset) + limit_expr = "None" if limit is None else str(limit) + return f""" +import json +from pathlib import Path + +path = Path({path!r}) +start = {start_expr} +limit = {limit_expr} +end = None if limit is None else start + limit +lines = [] +with path.open("r", encoding={encoding!r}, newline="") as file_obj: + for index, line in enumerate(file_obj): + if index < start: + continue + if end is not None and index >= end: + break + lines.append(line) +content = "".join(lines) +print(json.dumps({{"content": content}}, ensure_ascii=False)) +""".strip() + + +def _build_image_read_script(path: str) -> str: + return f""" +import base64 +import json +from pathlib import Path + +path = Path({path!r}) +data = path.read_bytes() +print( + json.dumps( + {{ + "size_bytes": len(data), + "base64": base64.b64encode(data).decode("ascii"), + }} + ) +) +""".strip() + + +def _looks_like_text(decoded: str) -> bool: + if not decoded: + return True + + disallowed = 0 + printable = 0 + for char in decoded: + if char in "\n\r\t\f\b": + printable += 1 + continue + if char.isprintable(): + printable += 1 + code = ord(char) + if (0 <= code < 32) or (127 <= code < 160): + disallowed += 1 + + total = max(len(decoded), 1) + return disallowed / total <= 0.02 and printable / total >= 0.85 + + +def detect_text_encoding(sample: bytes) -> str | None: + if not sample: + return "utf-8" + + if b"\x00" in sample and not sample.startswith(_UTF_BOMS): + odd_bytes = sample[1::2] + even_bytes = sample[0::2] + odd_zero_ratio = odd_bytes.count(0) / max(len(odd_bytes), 1) + even_zero_ratio = even_bytes.count(0) / max(len(even_bytes), 1) + if odd_zero_ratio < 0.8 and even_zero_ratio < 0.8: + return None + + for encoding in _TEXT_ENCODINGS: + try: + decoded = sample.decode(encoding) + except UnicodeDecodeError as exc: + # Probe samples can end in the middle of a multibyte sequence. + # When the decode failure only happens at the sample tail, trim a few + # bytes and retry so UTF-8 text is not misclassified as binary. + if exc.start >= len(sample) - 4: + decoded = "" + for trim_bytes in range(1, min(4, len(sample)) + 1): + try: + decoded = sample[:-trim_bytes].decode(encoding) + break + except UnicodeDecodeError: + continue + if not decoded: + continue + else: + continue + if _looks_like_text(decoded): + return encoding + + return None + + +def read_local_text_range_sync( + path: str, + *, + encoding: str, + offset: int | None, + limit: int | None, +) -> str: + lines: list[str] = [] + start = 0 if offset is None else offset + end = None if limit is None else start + limit + with open(path, encoding=encoding, newline="") as file_obj: + for index, line in enumerate(file_obj): + if index < start: + continue + if end is not None and index >= end: + break + lines.append(line) + return "".join(lines) + + +async def read_local_text_range( + path: str, + *, + encoding: str, + offset: int | None, + limit: int | None, +) -> str: + return await to_thread( + read_local_text_range_sync, + path, + encoding=encoding, + offset=offset, + limit=limit, + ) + + +async def _exec_python_json( + booter: ComputerBooter, + script: str, + *, + action: str, +) -> dict: + result = await booter.python.exec(script) + data = result.get("data") if isinstance(result.get("data"), dict) else {} + if not isinstance(data, dict): + raise RuntimeError(f"{action} failed: invalid result format") + output = data.get("output") if isinstance(data.get("output"), dict) else {} + if not isinstance(output, dict): + raise RuntimeError(f"{action} failed: invalid output format") + error_text = str(data.get("error", "") or result.get("error", "") or "").strip() + if error_text: + raise RuntimeError(f"{action} failed: {error_text}") + + text = str(output.get("text", "") or "").strip() + if not text: + raise RuntimeError(f"{action} failed: empty output") + + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + raise RuntimeError(f"{action} failed: invalid JSON output") from exc + + if not isinstance(payload, dict): + raise RuntimeError(f"{action} failed: invalid JSON payload") + return payload + + +async def _probe_local_file(path: str) -> dict[str, str | int]: + def _run() -> dict[str, str | int]: + file_path = Path(path) + with file_path.open("rb") as file_obj: + sample = file_obj.read(_FILE_SNIFF_BYTES) + return { + "size_bytes": file_path.stat().st_size, + "sample_b64": base64.b64encode(sample).decode("ascii"), + } + + return await to_thread(_run) + + +async def _read_local_image_base64(path: str) -> dict[str, str | int]: + def _run() -> dict[str, str | int]: + data = Path(path).read_bytes() + return { + "size_bytes": len(data), + "base64": base64.b64encode(data).decode("ascii"), + } + + return await to_thread(_run) + + +async def _read_local_file_bytes(path: str) -> bytes: + return await to_thread(Path(path).read_bytes) + + +async def _compress_image_bytes_to_base64(data: bytes) -> dict[str, str | int]: + def _run() -> dict[str, str | int]: + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + compressed_path = Path( + _compress_image_sync( + data, + temp_dir, + IMAGE_COMPRESS_DEFAULT_MAX_SIZE, + IMAGE_COMPRESS_DEFAULT_QUALITY, + IMAGE_COMPRESS_DEFAULT_OPTIMIZE, + ) + ) + try: + compressed_bytes = compressed_path.read_bytes() + finally: + compressed_path.unlink(missing_ok=True) + + return { + "size_bytes": len(compressed_bytes), + "base64": base64.b64encode(compressed_bytes).decode("ascii"), + "mime_type": "image/jpeg", + } + + return await to_thread(_run) + + +def _detect_image_mime(sample: bytes) -> str | None: + if sample.startswith(b"\x89PNG\r\n\x1a\n"): + return "image/png" + if sample.startswith(b"\xff\xd8\xff"): + return "image/jpeg" + if sample.startswith((b"GIF87a", b"GIF89a")): + return "image/gif" + if sample.startswith(b"BM"): + return "image/bmp" + if sample.startswith((b"II*\x00", b"MM\x00*")): + return "image/tiff" + if sample.startswith(b"\x00\x00\x01\x00"): + return "image/x-icon" + if len(sample) >= 12 and sample[:4] == b"RIFF" and sample[8:12] == b"WEBP": + return "image/webp" + if len(sample) >= 12 and sample[4:12] in (b"ftypavif", b"ftypavis"): + return "image/avif" + return None + + +def _looks_like_known_binary(sample: bytes) -> bool: + return any(sample.startswith(prefix) for prefix in _BINARY_MAGIC_PREFIXES) + + +def _looks_like_pdf(path: str, sample: bytes) -> bool: + return Path(path).suffix.lower() == ".pdf" or sample.startswith(b"%PDF-") + + +def _looks_like_zip_container(sample: bytes) -> bool: + return any(sample.startswith(prefix) for prefix in _ZIP_MAGIC_PREFIXES) + + +def _is_docx_bytes(file_bytes: bytes) -> bool: + try: + with zipfile.ZipFile(io.BytesIO(file_bytes)) as archive: + names = set(archive.namelist()) + except (OSError, zipfile.BadZipFile): + return False + + if "[Content_Types].xml" not in names: + return False + + return any(name.startswith("word/") for name in names) + + +async def _parse_local_docx_text(file_bytes: bytes, file_name: str) -> str: + from astrbot.core.knowledge_base.parsers.markitdown_parser import ( + MarkitdownParser, + ) + + result = await MarkitdownParser().parse(file_bytes, file_name) + return result.text + + +async def _parse_local_pdf_text(file_bytes: bytes, file_name: str) -> str: + from astrbot.core.knowledge_base.parsers.pdf_parser import PDFParser + + result = await PDFParser().parse(file_bytes, file_name) + return result.text + + +async def _parse_local_supported_document( + path: str, + sample: bytes, +) -> ParsedDocument | None: + file_name = Path(path).name + if _looks_like_pdf(path, sample): + file_bytes = await _read_local_file_bytes(path) + text = await _parse_local_pdf_text(file_bytes, file_name) + return ParsedDocument(kind="pdf", file_bytes=file_bytes, text=text) + + if Path(path).suffix.lower() == ".docx" or _looks_like_zip_container(sample): + file_bytes = await _read_local_file_bytes(path) + if not _is_docx_bytes(file_bytes): + return None + text = await _parse_local_docx_text(file_bytes, file_name) + return ParsedDocument(kind="docx", file_bytes=file_bytes, text=text) + + return None + + +def _probe_file(sample: bytes, *, size_bytes: int) -> FileProbe: + if image_mime := _detect_image_mime(sample): + return FileProbe( + kind="image", + encoding=None, + mime_type=image_mime, + size_bytes=size_bytes, + ) + + if _looks_like_known_binary(sample): + return FileProbe( + kind="binary", + encoding=None, + mime_type=None, + size_bytes=size_bytes, + ) + + if encoding := detect_text_encoding(sample): + return FileProbe( + kind="text", + encoding=encoding, + mime_type="text/plain", + size_bytes=size_bytes, + ) + + return FileProbe( + kind="binary", + encoding=None, + mime_type=None, + size_bytes=size_bytes, + ) + + +def _validate_text_output(content: str) -> str | None: + content_bytes = len(content.encode("utf-8")) + if content_bytes > _MAX_FILE_READ_BYTES: + return ( + "Error reading file: " + f"output exceeds {_MAX_FILE_READ_BYTES} bytes " + f"({content_bytes} bytes). Use `offset`, `limit` to narrow the read window." + ) + + content_tokens = _TOKEN_COUNTER.count_tokens( + [Message(role="user", content=content)] + ) + if content_tokens > _MAX_FILE_READ_TOKENS: + return ( + "Error reading file: " + f"output exceeds {_MAX_FILE_READ_TOKENS} tokens " + f"({content_tokens} tokens). Use `offset`, `limit` to narrow the read window." + ) + + return None + + +def _text_exceeds_read_thresholds(content: str) -> bool: + return _validate_text_output(content) is not None + + +def _validate_full_text_read_request(probe: FileProbe) -> str | None: + if probe.size_bytes > _MAX_TEXT_FILE_FULL_READ_BYTES: + return ( + "Error reading file: " + f"text file exceeds {_MAX_TEXT_FILE_FULL_READ_BYTES} bytes " + f"({probe.size_bytes} bytes). Use `offset` and `limit` to narrow the read window." + ) + return None + + +def _slice_text_by_lines( + content: str, + *, + offset: int | None, + limit: int | None, +) -> str: + if offset is None and limit is None: + return content + + lines = content.splitlines(keepends=True) + start = 0 if offset is None else offset + end = None if limit is None else start + limit + return "".join(lines[start:end]) + + +async def _store_converted_text_for_workspace( + *, + workspace_dir: str, + original_path: str, + original_bytes: bytes, + content: str, +) -> str: + def _run() -> str: + original_name = Path(original_path).name + digest_suffix = hashlib.md5(original_bytes).hexdigest()[-6:] + target_dir = ( + Path(workspace_dir) / "converted_files" / f"{original_name}_{digest_suffix}" + ) + target_dir.mkdir(parents=True, exist_ok=True) + target_path = target_dir / "text.txt" + target_path.write_text(content, encoding="utf-8") + return str(target_path) + + return await to_thread(_run) + + +def _build_converted_text_notice( + converted_text_path: str, + *, + selection_returned: bool, + selection_too_large: bool = False, +) -> str: + if selection_too_large: + return ( + "Converted text was saved to " + f"`{converted_text_path}`. The requested output is still too large to " + "return directly. Read or grep that file with a narrower window." + ) + + if selection_returned: + return ( + "Full converted text is also available at " + f"`{converted_text_path}`. Read or grep that file with a narrow " + "window for additional reads." + ) + + return ( + "Converted text was saved to " + f"`{converted_text_path}` because the parsed document is too large to " + "return directly. Read or grep that file with a narrow window." + ) + + +async def _read_local_supported_document_result( + *, + path: str, + parsed_document: ParsedDocument, + workspace_dir: str | None, + offset: int | None, + limit: int | None, +) -> ToolExecResult: + content = parsed_document.text + if not content: + return "No content found at the requested line offset." + + if not _text_exceeds_read_thresholds(content): + selected_content = _slice_text_by_lines(content, offset=offset, limit=limit) + if not selected_content: + return "No content found at the requested line offset." + if validation_error := _validate_text_output(selected_content): + return validation_error + return selected_content + + if not workspace_dir: + return ( + "Error reading file: parsed document exceeds the read output limit and " + "no workspace is available for storing converted text." + ) + + converted_text_path = await _store_converted_text_for_workspace( + workspace_dir=workspace_dir, + original_path=path, + original_bytes=parsed_document.file_bytes, + content=content, + ) + + if offset is None and limit is None: + return _build_converted_text_notice( + converted_text_path, + selection_returned=False, + ) + + selected_content = _slice_text_by_lines(content, offset=offset, limit=limit) + if not selected_content: + return ( + "No content found at the requested line offset. " + + _build_converted_text_notice( + converted_text_path, + selection_returned=False, + ) + ) + + notice = _build_converted_text_notice( + converted_text_path, + selection_returned=True, + ) + combined_output = f"{selected_content}\n\n[{notice}]" + if _validate_text_output(combined_output): + if _validate_text_output(selected_content): + return _build_converted_text_notice( + converted_text_path, + selection_returned=False, + selection_too_large=True, + ) + return selected_content + + return combined_output + + +async def read_file_tool_result( + booter: ComputerBooter, + *, + local_mode: bool, + path: str, + offset: int | None, + limit: int | None, + workspace_dir: str | None = None, +) -> ToolExecResult: + if local_mode: + probe_payload = await _probe_local_file(path) + else: + probe_payload = await _exec_python_json( + booter, + _build_probe_script(path), + action="file probe", + ) + sample_b64 = str(probe_payload.get("sample_b64", "") or "") + sample = base64.b64decode(sample_b64) if sample_b64 else b"" + size_bytes = int(probe_payload.get("size_bytes", 0) or 0) + probe = _probe_file(sample, size_bytes=size_bytes) + + if local_mode: + try: + parsed_document = await _parse_local_supported_document(path, sample) + except Exception as exc: + return f"Error reading file: failed to parse document: {exc}" + + if parsed_document is not None: + return await _read_local_supported_document_result( + path=path, + parsed_document=parsed_document, + workspace_dir=workspace_dir, + offset=offset, + limit=limit, + ) + + if probe.kind == "binary": + return "Error reading file: binary files are not supported by this tool." + + if probe.kind == "image": + if local_mode: + image_payload = await _read_local_image_base64(path) + else: + image_payload = await _exec_python_json( + booter, + _build_image_read_script(path), + action="image read", + ) + raw_base64_data = str(image_payload.get("base64", "") or "") + if not raw_base64_data: + return "Error reading file: image payload is empty." + raw_bytes = base64.b64decode(raw_base64_data) + compressed_payload = await _compress_image_bytes_to_base64(raw_bytes) + base64_data = str(compressed_payload.get("base64", "") or "") + if not base64_data: + return "Error reading file: compressed image payload is empty." + return mcp.types.CallToolResult( + content=[ + mcp.types.ImageContent( + type="image", + data=base64_data, + mimeType=str( + compressed_payload.get("mime_type", "") or "image/jpeg" + ), + ) + ] + ) + + if offset is None and limit is None: + if validation_error := _validate_full_text_read_request(probe): + return validation_error + + if local_mode: + content = await read_local_text_range( + path, + encoding=probe.encoding or "utf-8", + offset=offset, + limit=limit, + ) + else: + text_payload = await _exec_python_json( + booter, + _build_text_read_script( + path, + encoding=probe.encoding or "utf-8", + offset=offset, + limit=limit, + ), + action="text read", + ) + content = str(text_payload.get("content", "") or "") + + if not content: + return "No content found at the requested line offset." + + if validation_error := _validate_text_output(content): + return validation_error + + return content diff --git a/astrbot/core/computer/olayer/filesystem.py b/astrbot/core/computer/olayer/filesystem.py index 86d2b4b36..6f007f94a 100644 --- a/astrbot/core/computer/olayer/filesystem.py +++ b/astrbot/core/computer/olayer/filesystem.py @@ -13,8 +13,36 @@ class FileSystemComponent(Protocol): """Create a file with the specified content""" ... - async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]: - """Read file content""" + async def read_file( + self, + path: str, + encoding: str = "utf-8", + offset: int | None = None, + limit: int | None = None, + ) -> dict[str, Any]: + """Read file content by line window""" + ... + + async def search_files( + self, + pattern: str, + path: str | None = None, + glob: str | None = None, + after_context: int | None = None, + before_context: int | None = None, + ) -> dict[str, Any]: + """Search file contents""" + ... + + async def edit_file( + self, + path: str, + old_string: str, + new_string: str, + replace_all: bool = False, + encoding: str = "utf-8", + ) -> dict[str, Any]: + """Edit file content by string replacement""" ... async def write_file( diff --git a/astrbot/core/exceptions.py b/astrbot/core/exceptions.py index f10af57ea..76c71af57 100644 --- a/astrbot/core/exceptions.py +++ b/astrbot/core/exceptions.py @@ -11,3 +11,22 @@ class ProviderNotFoundError(AstrBotError): class EmptyModelOutputError(AstrBotError): """Raised when the model response contains no usable assistant output.""" + + +class KnowledgeBaseUploadError(AstrBotError): + """Raised when knowledge base upload fails with a user-facing message.""" + + def __init__( + self, + *, + stage: str, + user_message: str, + details: dict | None = None, + ) -> None: + super().__init__(user_message) + self.stage = stage + self.user_message = user_message + self.details = details or {} + + def __str__(self) -> str: + return self.user_message diff --git a/astrbot/core/platform/sources/discord/client.py b/astrbot/core/platform/sources/discord/client.py index 385cd95c2..295aaacc7 100644 --- a/astrbot/core/platform/sources/discord/client.py +++ b/astrbot/core/platform/sources/discord/client.py @@ -15,9 +15,12 @@ else: class DiscordBotClient(discord.Bot): """Discord客户端封装""" - def __init__(self, token: str, proxy: str | None = None) -> None: + def __init__( + self, token: str, proxy: str | None = None, allow_bot_messages: bool = False + ) -> None: self.token = token self.proxy = proxy + self.allow_bot_messages = allow_bot_messages # 设置Intent权限,遵循权限最小化原则 intents = discord.Intents.default() @@ -95,7 +98,7 @@ class DiscordBotClient(discord.Bot): async def on_message(self, message: discord.Message) -> None: """当接收到消息时触发""" - if message.author.bot: + if message.author.bot and not self.allow_bot_messages: return logger.debug( diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index 90d9ab0f4..06ddaa436 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -521,6 +521,26 @@ class ProviderManager: pc = merged_config return pc + def get_provider_config_by_id( + self, + provider_id: str, + *, + merged: bool = False, + ) -> dict | None: + """Get a provider config by id. + + Args: + provider_id: Provider id to resolve. + merged: Whether to merge provider_source config into the provider config. + """ + for provider_config in self.providers_config: + if provider_config.get("id") != provider_id: + continue + if merged: + return self.get_merged_provider_config(provider_config) + return copy.deepcopy(provider_config) + return None + def _resolve_env_key_list(self, provider_config: dict) -> dict: keys = provider_config.get("key", []) if not isinstance(keys, list): diff --git a/astrbot/core/star/register/__init__.py b/astrbot/core/star/register/__init__.py index 88b576de9..467de0f3d 100644 --- a/astrbot/core/star/register/__init__.py +++ b/astrbot/core/star/register/__init__.py @@ -7,6 +7,8 @@ from .star_handler import ( register_custom_filter, register_event_message_type, register_llm_tool, + register_on_agent_begin, + register_on_agent_done, register_on_astrbot_loaded, register_on_decorating_result, register_on_llm_request, @@ -31,6 +33,8 @@ __all__ = [ "register_custom_filter", "register_event_message_type", "register_llm_tool", + "register_on_agent_begin", + "register_on_agent_done", "register_on_astrbot_loaded", "register_on_decorating_result", "register_on_llm_request", diff --git a/astrbot/core/star/register/star_handler.py b/astrbot/core/star/register/star_handler.py index 3d3752649..76116d0bd 100644 --- a/astrbot/core/star/register/star_handler.py +++ b/astrbot/core/star/register/star_handler.py @@ -470,6 +470,64 @@ def register_on_llm_response(**kwargs): return decorator +def register_on_agent_begin(**kwargs): + """当 Agent 开始运行时的事件 + + Examples: + ```py + from astrbot.core.agent.run_context import ContextWrapper + from astrbot.core.astr_agent_context import AstrAgentContext + + @on_agent_begin() + async def test( + self, + event: AstrMessageEvent, + run_context: ContextWrapper[AstrAgentContext], + ) -> None: + ... + ``` + + 请务必接收两个参数:event, run_context + + """ + + def decorator(awaitable): + _ = get_handler_or_create(awaitable, EventType.OnAgentBeginEvent, **kwargs) + return awaitable + + return decorator + + +def register_on_agent_done(**kwargs): + """当 Agent 运行完成后的事件 + + Examples: + ```py + from astrbot.core.agent.run_context import ContextWrapper + from astrbot.core.astr_agent_context import AstrAgentContext + from astrbot.api.provider import LLMResponse + + @on_agent_done() + async def test( + self, + event: AstrMessageEvent, + run_context: ContextWrapper[AstrAgentContext], + response: LLMResponse, + ) -> None: + ... + ``` + + 请务必接收三个参数:event, run_context, response + + """ + + def decorator(awaitable): + _ = get_handler_or_create(awaitable, EventType.OnAgentDoneEvent, **kwargs) + return awaitable + + return decorator + + def register_on_using_llm_tool(**kwargs): """当调用函数工具前的事件。 会传入 tool 和 tool_args 参数。 diff --git a/astrbot/core/star/star_handler.py b/astrbot/core/star/star_handler.py index 593159dc6..c55dbad97 100644 --- a/astrbot/core/star/star_handler.py +++ b/astrbot/core/star/star_handler.py @@ -71,6 +71,22 @@ class StarHandlerRegistry(Generic[T]): plugins_name: list[str] | None = None, ) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ... + @overload + def get_handlers_by_event_type( + self, + event_type: Literal[EventType.OnAgentBeginEvent], + only_activated=True, + plugins_name: list[str] | None = None, + ) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ... + + @overload + def get_handlers_by_event_type( + self, + event_type: Literal[EventType.OnAgentDoneEvent], + only_activated=True, + plugins_name: list[str] | None = None, + ) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ... + @overload def get_handlers_by_event_type( self, @@ -213,6 +229,8 @@ class EventType(enum.Enum): OnWaitingLLMRequestEvent = enum.auto() # 等待调用 LLM(在获取锁之前,仅通知) OnLLMRequestEvent = enum.auto() # 收到 LLM 请求(可以是用户也可以是插件) OnLLMResponseEvent = enum.auto() # LLM 响应后 + OnAgentBeginEvent = enum.auto() # Agent 开始运行 + OnAgentDoneEvent = enum.auto() # Agent 运行完成 OnDecoratingResultEvent = enum.auto() # 发送消息前 OnCallingFuncToolEvent = enum.auto() # 调用函数工具 OnUsingLLMToolEvent = enum.auto() # 使用 LLM 工具 diff --git a/astrbot/core/tools/computer_tools/__init__.py b/astrbot/core/tools/computer_tools/__init__.py new file mode 100644 index 000000000..7e364ffd2 --- /dev/null +++ b/astrbot/core/tools/computer_tools/__init__.py @@ -0,0 +1,55 @@ +from .fs import ( + FileDownloadTool, + FileEditTool, + FileReadTool, + FileUploadTool, + FileWriteTool, + GrepTool, +) +from .python import LocalPythonTool, PythonTool +from .shell import ExecuteShellTool +from .shipyard_neo import ( + AnnotateExecutionTool, + BrowserBatchExecTool, + BrowserExecTool, + CreateSkillCandidateTool, + CreateSkillPayloadTool, + EvaluateSkillCandidateTool, + GetExecutionHistoryTool, + GetSkillPayloadTool, + ListSkillCandidatesTool, + ListSkillReleasesTool, + PromoteSkillCandidateTool, + RollbackSkillReleaseTool, + RunBrowserSkillTool, + SyncSkillReleaseTool, +) +from .util import check_admin_permission, normalize_umo_for_workspace + +__all__ = [ + "AnnotateExecutionTool", + "BrowserBatchExecTool", + "BrowserExecTool", + "CreateSkillCandidateTool", + "CreateSkillPayloadTool", + "EvaluateSkillCandidateTool", + "ExecuteShellTool", + "FileDownloadTool", + "FileEditTool", + "FileReadTool", + "FileUploadTool", + "FileWriteTool", + "GetExecutionHistoryTool", + "GetSkillPayloadTool", + "GrepTool", + "ListSkillCandidatesTool", + "ListSkillReleasesTool", + "LocalPythonTool", + "PromoteSkillCandidateTool", + "PythonTool", + "RollbackSkillReleaseTool", + "RunBrowserSkillTool", + "SyncSkillReleaseTool", + "normalize_umo_for_workspace", + "check_admin_permission", +] diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py new file mode 100644 index 000000000..8d3160ff5 --- /dev/null +++ b/astrbot/core/tools/computer_tools/fs.py @@ -0,0 +1,749 @@ +"""Filesystem tool audit. + +Tool exposure from the main agent: +- Local runtime exposes `astrbot_read_file_tool`, `astrbot_file_write_tool`, + `astrbot_file_edit_tool`, and `astrbot_grep_tool`. +- Sandbox runtime exposes `astrbot_upload_file`, `astrbot_download_file`, + `astrbot_read_file_tool`, `astrbot_file_write_tool`, + `astrbot_file_edit_tool`, and `astrbot_grep_tool`. + +Behavior when `provider_settings.computer_use_require_admin=True`: +- Admin + local: read/write/edit/grep are not path-restricted by this module; + access depends on the local runtime implementation and host OS permissions. + Upload and download tools are defined here, but `LocalBooter` does not + implement them and the main agent does not expose them in local mode. +- Member + local: read/write/edit/grep are restricted to `data/skills`, + `data/workspaces/{normalized_umo}`, and `/tmp/.astrbot`. Upload/download are + denied by `check_admin_permission` if invoked. +- Admin + sandbox: read/write/edit/grep are not path-restricted by this + module; + sandbox filesystem boundaries are enforced by the sandbox runtime. Upload and + download are allowed. +- Member + sandbox: read/write/edit/grep are also not path-restricted by this + module. Upload/download are denied by `check_admin_permission` if invoked. + +When `computer_use_require_admin=False`, member behavior in this module matches +admin behavior. + +Local path resolution rule: +- In local runtime, relative paths are resolved under + `data/workspaces/{normalized_umo}`. +- In sandbox runtime, relative paths are passed through unchanged. +""" + +import os +import uuid +from dataclasses import dataclass, field +from pathlib import Path + +from astrbot.api import FunctionTool, logger +from astrbot.api.event import MessageChain +from astrbot.core.agent.run_context import ContextWrapper +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.computer.file_read_utils import read_file_tool_result +from astrbot.core.message.components import File +from astrbot.core.utils.astrbot_path import ( + get_astrbot_skills_path, + get_astrbot_system_tmp_path, + get_astrbot_temp_path, +) + +from ..registry import builtin_tool +from . import util as computer_util +from .util import ( + check_admin_permission, + is_local_runtime, + normalize_umo_for_workspace, +) + +_COMPUTER_RUNTIME_TOOL_CONFIG = { + "provider_settings.computer_use_runtime": ("local", "sandbox"), +} +_SANDBOX_RUNTIME_TOOL_CONFIG = { + "provider_settings.computer_use_runtime": "sandbox", +} + + +def _restricted_env_path_labels(umo: str) -> list[str]: + """Labels for the allowed directories in a local(not sandbox) and restricted(not admin) environment""" + normalized_umo = normalize_umo_for_workspace(umo) + return [ + "data/skills", + f"data/workspaces/{normalized_umo}", + get_astrbot_system_tmp_path(), + get_astrbot_temp_path(), + ] + + +def get_astrbot_workspaces_path() -> str: + """Compatibility wrapper for tests and older module-level monkeypatches.""" + return computer_util.get_astrbot_workspaces_path() + + +def _workspace_root(umo: str) -> Path: + """Workspace root that follows both util-level and fs-level getter monkeypatches.""" + normalized_umo = normalize_umo_for_workspace(umo) + return (Path(get_astrbot_workspaces_path()) / normalized_umo).resolve(strict=False) + + +def _read_allowed_roots(umo: str) -> tuple[Path, ...]: + """Non-admin users can only read files within these directories (and their subdirectories)""" + return ( + Path(get_astrbot_skills_path()).resolve(strict=False), + _workspace_root(umo), + Path(get_astrbot_system_tmp_path()).resolve(strict=False), + Path(get_astrbot_temp_path()).resolve(strict=False), + ) + + +def _is_restricted_env(context: ContextWrapper[AstrAgentContext]) -> bool: + if not is_local_runtime(context): + return False + cfg = context.context.context.get_config( + umo=context.context.event.unified_msg_origin + ) + provider_settings = cfg.get("provider_settings", {}) + require_admin = provider_settings.get("computer_use_require_admin", True) + return require_admin and context.context.event.role != "admin" + + +def _resolve_tool_path(path: str, *, local_env: bool, umo: str) -> str: + normalized_path = path.strip() + if not normalized_path: + return normalized_path + candidate = Path(normalized_path).expanduser() + if candidate.is_absolute(): + return str(candidate.resolve(strict=False)) + if local_env: + return str((_workspace_root(umo) / candidate).resolve(strict=False)) + return normalized_path + + +def _resolve_user_path(path: str, *, local_env: bool, umo: str) -> Path: + candidate = Path(path).expanduser() + if candidate.is_absolute(): + return candidate.resolve(strict=False) + if local_env: + return (_workspace_root(umo) / candidate).resolve(strict=False) + return (Path.cwd() / candidate).resolve(strict=False) + + +def _is_path_within_allowed_roots(path: str, umo: str) -> bool: + resolved = _resolve_user_path(path, local_env=True, umo=umo) + return any( + resolved == allowed_root or resolved.is_relative_to(allowed_root) + for allowed_root in _read_allowed_roots(umo) + ) + + +def _normalize_rw_path( + path: str, + *, + restricted: bool, + local_env: bool, + umo: str, +) -> str: + normalized_path = _resolve_tool_path(path, local_env=local_env, umo=umo) + if not normalized_path: + raise ValueError("`path` must be a non-empty string.") + if restricted and not _is_path_within_allowed_roots(normalized_path, umo): + allowed = ", ".join(_restricted_env_path_labels(umo)) + raise PermissionError( + "Read access is restricted for this user. " + f"Allowed directories: {allowed}. Blocked path: {normalized_path}." + ) + return normalized_path + + +def _decode_escaped_text(value: str) -> str: + """Decode common escaped control sequences used in tool arguments.""" + return ( + value.replace("\\r\\n", "\n") + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t") + ) + + +@builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG) +@dataclass +class FileReadTool(FunctionTool): + name: str = "astrbot_file_read_tool" + description: str = "read file content." + parameters: dict = field( + default_factory=lambda: { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path of the file to read. If relative, will be in workspace root.", + }, + "offset": { + "type": "integer", + "description": "Optional line offset to start reading from. 0-based index.", + "minimum": 0, + }, + "limit": { + "type": "integer", + "description": "Optional maximum number of lines to read.", + "minimum": 1, + }, + }, + "required": ["path"], + } + ) + + def _validate_read_window( + self, + offset: int | None, + limit: int | None, + ) -> tuple[int | None, int | None]: + if offset is not None and offset < 0: + raise ValueError("`offset` must be greater than or equal to 0.") + if limit is not None and limit < 1: + raise ValueError("`limit` must be greater than or equal to 1.") + return offset, limit + + async def call( + self, + context: ContextWrapper[AstrAgentContext], + path: str, + offset: int | None = None, + limit: int | None = None, + ) -> ToolExecResult: + local_env = is_local_runtime(context) + restricted = _is_restricted_env(context) + try: + normalized_path = ( + _normalize_rw_path( + path, + restricted=restricted, + local_env=local_env, + umo=context.context.event.unified_msg_origin, + ) + if local_env + else path.strip() + ) + if not normalized_path: + raise ValueError("`path` must be a non-empty string.") + offset, limit = self._validate_read_window(offset, limit) + sb = await get_booter( + context.context.context, + context.context.event.unified_msg_origin, + ) + return await read_file_tool_result( + sb, + local_mode=local_env, + path=normalized_path, + offset=offset, + limit=limit, + workspace_dir=( + str(_workspace_root(context.context.event.unified_msg_origin)) + if local_env + else None + ), + ) + except PermissionError as exc: + return f"Error: {exc}" + except Exception as exc: + logger.error(f"Error reading file: {exc}") + return f"Error reading file: {exc}" + + +@builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG) +@dataclass +class FileWriteTool(FunctionTool): + name: str = "astrbot_file_write_tool" + description: str = "Write UTF-8 text content to a file." + parameters: dict = field( + default_factory=lambda: { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path of the file to write. If relative, will be in workspace root.", + }, + "content": { + "type": "string", + "description": "The content to write to the file", + }, + }, + "required": ["path", "content"], + } + ) + + async def call( + self, + context: ContextWrapper[AstrAgentContext], + path: str, + content: str, + ) -> ToolExecResult: + local_env = is_local_runtime(context) + restricted = _is_restricted_env(context) + try: + normalized_path = ( + _normalize_rw_path( + path, + restricted=restricted, + local_env=local_env, + umo=context.context.event.unified_msg_origin, + ) + if local_env + else path.strip() + ) + if not normalized_path: + raise ValueError("`path` must be a non-empty string.") + sb = await get_booter( + context.context.context, + context.context.event.unified_msg_origin, + ) + result = await sb.fs.write_file( + path=normalized_path, + content=content, + mode="w", + encoding="utf-8", + ) + if not result.get("success", False): + error_detail = str(result.get("error", "") or "").strip() + return ( + "Error writing file: " + f"{error_detail or 'unknown filesystem write error'}" + ) + return f"File written successfully: {normalized_path}" + except PermissionError as exc: + return f"Error: {exc}" + except Exception as exc: + logger.error(f"Error writing file: {exc}") + return f"Error writing file: {exc}" + + +@builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG) +@dataclass +class FileEditTool(FunctionTool): + name: str = "astrbot_file_edit_tool" + description: str = "Editing files." + parameters: dict = field( + default_factory=lambda: { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path of the file to edit. If relative, will be in workspace root.", + }, + "old": { + "type": "string", + "description": "The exact old text to replace.", + }, + "new": { + "type": "string", + "description": "The replacement text.", + }, + "replace_all": { + "type": "boolean", + "description": "Whether to replace all matches. Defaults to false.", + }, + }, + "required": ["path", "old", "new"], + } + ) + + async def call( + self, + context: ContextWrapper[AstrAgentContext], + path: str, + old: str, + new: str, + replace_all: bool = False, + ) -> ToolExecResult: + umo = str(context.context.event.unified_msg_origin) + local_env = is_local_runtime(context) + restricted = _is_restricted_env(context) + try: + normalized_path = ( + _normalize_rw_path( + path, + restricted=restricted, + local_env=local_env, + umo=umo, + ) + if local_env + else path.strip() + ) + if not normalized_path: + raise ValueError("`path` must be a non-empty string.") + normalized_old = _decode_escaped_text(old) + normalized_new = _decode_escaped_text(new) + sb = await get_booter( + context.context.context, + context.context.event.unified_msg_origin, + ) + result = await sb.fs.edit_file( + path=normalized_path, + old_string=normalized_old, + new_string=normalized_new, + replace_all=replace_all, + encoding="utf-8", + ) + if not result.get("success", False): + error_detail = str(result.get("error", "") or "").strip() + return ( + "Error editing file: " + f"{error_detail or 'unknown filesystem edit error'}" + ) + replacements = int(result.get("replacements", 0) or 0) + mode_text = "all matches" if replace_all else "first match" + return ( + f"Edited {normalized_path}. " + f"Replaced {replacements} occurrence(s) using {mode_text} mode." + ) + except PermissionError as exc: + return f"Error: {exc}" + except Exception as exc: + logger.error(f"Error editing file: {exc}") + return f"Error editing file: {exc}" + + +@builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG) +@dataclass +class GrepTool(FunctionTool): + name: str = "astrbot_grep_tool" + description: str = "Search and read file contents using ripgrep." + parameters: dict = field( + default_factory=lambda: { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "The expression pattern to search for in file contents.", + }, + "path": { + "type": "string", + "description": "File or directory to search in (rg PATH). If relative, will be in workspace root.", + }, + "glob": { + "type": "string", + "description": "Optional glob filter such as `*.py`, `*.{ts,tsx}`.", + }, + "-A": { + "type": "integer", + "description": "Number of trailing context lines to include after each match.", + "minimum": 0, + }, + "-B": { + "type": "integer", + "description": "Number of leading context lines to include before each match.", + "minimum": 0, + }, + "-C": { + "type": "integer", + "description": "Number of leading and trailing context lines to include around each match.", + "minimum": 0, + }, + "result_limit": { + "type": "integer", + "description": "Maximum number of result groups returned by the tool. Defaults to 100.", + "minimum": 1, + }, + }, + "required": ["pattern"], + } + ) + + def _resolve_context_options( + self, + after_context: int | None, + before_context: int | None, + context: int | None, + ) -> tuple[int | None, int | None]: + if context is not None and context < 0: + raise ValueError("`-C` must be greater than or equal to 0.") + if after_context is not None and after_context < 0: + raise ValueError("`-A` must be greater than or equal to 0.") + if before_context is not None and before_context < 0: + raise ValueError("`-B` must be greater than or equal to 0.") + + resolved_after = context if after_context is None else after_context + resolved_before = context if before_context is None else before_context + return resolved_after, resolved_before + + def _split_output_groups(self, output: str, *, has_context: bool) -> list[str]: + if not output.strip(): + return [] + + if not has_context: + return [f"{line}\n" for line in output.splitlines() if line.strip()] + + groups: list[str] = [] + current: list[str] = [] + + for line in output.splitlines(keepends=True): + if line.strip() == "--": + if current: + groups.append("".join(current)) + current = [] + continue + if not line.strip(): + continue + current.append(line) + + if current: + groups.append("".join(current)) + return groups + + def _apply_result_limit( + self, + output: str, + *, + result_limit: int, + has_context: bool, + ) -> str: + if result_limit < 1: + raise ValueError("`result_limit` must be greater than or equal to 1.") + + groups = self._split_output_groups(output, has_context=has_context) + if len(groups) <= result_limit: + return output if output.strip() else "No matches found." + + limited_output = "".join(groups[:result_limit]).rstrip() + return f"{limited_output}\n\n[Truncated to first {result_limit} result groups.]" + + def _normalize_search_paths( + self, + path: str | None, + *, + restricted: bool, + local_env: bool, + umo: str, + ) -> list[str]: + normalized = ( + [_resolve_tool_path(path, local_env=local_env, umo=umo)] if path else [] + ) + if not normalized: + if restricted: + return [str(root) for root in _read_allowed_roots(umo)] + if local_env: + return [str(_workspace_root(umo))] + return ["."] + + if restricted: + disallowed = [ + path + for path in normalized + if not _is_path_within_allowed_roots(path, umo) + ] + if disallowed: + allowed = ", ".join(_restricted_env_path_labels(umo)) + blocked = ", ".join(disallowed) + raise PermissionError( + "Read access is restricted for this user. " + f"Allowed directories: {allowed}. Blocked paths: {blocked}." + ) + + return normalized + + async def call( + self, + context: ContextWrapper[AstrAgentContext], + pattern: str, + path: str | None = None, + glob: str | None = None, + result_limit: int = 100, + **kwargs, + ) -> ToolExecResult: + normalized_pattern = pattern.strip() + if not normalized_pattern: + return "Error: `pattern` must be a non-empty string." + + local_env = is_local_runtime(context) + restricted = _is_restricted_env(context) + try: + search_paths = ( + self._normalize_search_paths( + path, + restricted=restricted, + local_env=local_env, + umo=context.context.event.unified_msg_origin, + ) + if local_env + else ([path.strip()] if path and path.strip() else ["."]) + ) + after_context, before_context = self._resolve_context_options( + kwargs.get("-A"), + kwargs.get("-B"), + kwargs.get("-C"), + ) + has_context = (after_context or 0) > 0 or (before_context or 0) > 0 + sb = await get_booter( + context.context.context, + context.context.event.unified_msg_origin, + ) + contents: list[str] = [] + for search_path in search_paths: + result = await sb.fs.search_files( + pattern=normalized_pattern, + path=search_path, + glob=glob, + after_context=after_context, + before_context=before_context, + ) + if not result.get("success", False): + error_detail = str(result.get("error", "") or "").strip() + logger.error("GrepTool search failed: %s", error_detail) + return ( + "Error searching files: " + f"{error_detail or 'unknown filesystem search error'}" + ) + content = str(result.get("content", "") or "") + if content: + contents.append(content) + + return self._apply_result_limit( + "".join(contents), + result_limit=result_limit, + has_context=has_context, + ) + except PermissionError as exc: + return f"Error: {exc}" + except Exception as exc: + logger.error(f"Error searching files: {exc}") + return f"Error searching files: {exc}" + + +@builtin_tool(config=_SANDBOX_RUNTIME_TOOL_CONFIG) +@dataclass +class FileUploadTool(FunctionTool): + name: str = "astrbot_upload_file" + description: str = ( + "Transfer a file FROM the host machine INTO the sandbox so that sandbox " + "code can access it. Use this when the user sends/attaches a file and you " + "need to process it inside the sandbox. The local_path must point to an " + "existing file on the host filesystem." + ) + parameters: dict = field( + default_factory=lambda: { + "type": "object", + "properties": { + "local_path": { + "type": "string", + "description": "Absolute path to the file on the host filesystem that will be copied into the sandbox.", + }, + # "remote_path": { + # "type": "string", + # "description": "The filename to use in the sandbox. If not provided, file will be saved to the working directory with the same name as the local file.", + # }, + }, + "required": ["local_path"], + } + ) + + async def call( + self, + context: ContextWrapper[AstrAgentContext], + local_path: str, + ) -> str | None: + if permission_error := check_admin_permission(context, "File upload/download"): + return permission_error + sb = await get_booter( + context.context.context, + context.context.event.unified_msg_origin, + ) + try: + # Check if file exists + if not os.path.exists(local_path): + return f"Error: File does not exist: {local_path}" + + if not os.path.isfile(local_path): + return f"Error: Path is not a file: {local_path}" + + # Use basename if sandbox_filename is not provided + remote_path = os.path.basename(local_path) + + # Upload file to sandbox + result = await sb.upload_file(local_path, remote_path) + logger.debug(f"Upload result: {result}") + success = result.get("success", False) + + if not success: + return f"Error uploading file: {result.get('message', 'Unknown error')}" + + file_path = result.get("file_path", "") + logger.info(f"File {local_path} uploaded to sandbox at {file_path}") + + return f"File uploaded successfully to {file_path}" + except Exception as e: + logger.error(f"Error uploading file {local_path}: {e}") + return f"Error uploading file: {str(e)}" + + +@builtin_tool(config=_SANDBOX_RUNTIME_TOOL_CONFIG) +@dataclass +class FileDownloadTool(FunctionTool): + name: str = "astrbot_download_file" + description: str = ( + "Transfer a file FROM the sandbox OUT to the host and optionally send it " + "to the user. Use this ONLY when the user asks to retrieve/export a file " + "that was created or modified inside the sandbox." + ) + parameters: dict = field( + default_factory=lambda: { + "type": "object", + "properties": { + "remote_path": { + "type": "string", + "description": "Path of the file inside the sandbox to copy out to the host.", + }, + "also_send_to_user": { + "type": "boolean", + "description": "Whether to also send the downloaded file to the user via message. Defaults to true.", + }, + }, + "required": ["remote_path"], + } + ) + + async def call( + self, + context: ContextWrapper[AstrAgentContext], + remote_path: str, + also_send_to_user: bool = True, + ) -> ToolExecResult: + if permission_error := check_admin_permission(context, "File upload/download"): + return permission_error + sb = await get_booter( + context.context.context, + context.context.event.unified_msg_origin, + ) + try: + name = os.path.basename(remote_path) + + local_path = os.path.join( + get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}" + ) + + # Download file from sandbox + await sb.download_file(remote_path, local_path) + logger.info(f"File {remote_path} downloaded from sandbox to {local_path}") + + if also_send_to_user: + try: + name = os.path.basename(local_path) + await context.context.event.send( + MessageChain(chain=[File(name=name, file=local_path)]) + ) + except Exception as e: + logger.error(f"Error sending file message: {e}") + + # remove + # try: + # os.remove(local_path) + # except Exception as e: + # logger.error(f"Error removing temp file {local_path}: {e}") + + return f"File downloaded successfully to {local_path} and sent to user." + + return f"File downloaded successfully to {local_path}" + except Exception as e: + logger.error(f"Error downloading file {remote_path}: {e}") + return f"Error downloading file: {str(e)}" diff --git a/astrbot/core/computer/tools/python.py b/astrbot/core/tools/computer_tools/python.py similarity index 91% rename from astrbot/core/computer/tools/python.py rename to astrbot/core/tools/computer_tools/python.py index ea098b5de..1e5edfc99 100644 --- a/astrbot/core/computer/tools/python.py +++ b/astrbot/core/tools/computer_tools/python.py @@ -8,10 +8,18 @@ from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.tool import ToolExecResult from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent from astrbot.core.computer.computer_client import get_booter, get_local_booter -from astrbot.core.computer.tools.permissions import check_admin_permission from astrbot.core.message.message_event_result import MessageChain +from ..registry import builtin_tool +from .util import check_admin_permission + _OS_NAME = platform.system() +_SANDBOX_PYTHON_TOOL_CONFIG = { + "provider_settings.computer_use_runtime": "sandbox", +} +_LOCAL_PYTHON_TOOL_CONFIG = { + "provider_settings.computer_use_runtime": "local", +} param_schema = { "type": "object", @@ -63,6 +71,7 @@ async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult return resp +@builtin_tool(config=_SANDBOX_PYTHON_TOOL_CONFIG) @dataclass class PythonTool(FunctionTool): name: str = "astrbot_execute_ipython" @@ -88,6 +97,7 @@ class PythonTool(FunctionTool): return f"Error executing code: {e!s}" +@builtin_tool(config=_LOCAL_PYTHON_TOOL_CONFIG) @dataclass class LocalPythonTool(FunctionTool): name: str = "astrbot_execute_python" diff --git a/astrbot/core/computer/tools/shell.py b/astrbot/core/tools/computer_tools/shell.py similarity index 63% rename from astrbot/core/computer/tools/shell.py rename to astrbot/core/tools/computer_tools/shell.py index 657110902..3a15b9f58 100644 --- a/astrbot/core/computer/tools/shell.py +++ b/astrbot/core/tools/computer_tools/shell.py @@ -5,11 +5,17 @@ from astrbot.api import FunctionTool from astrbot.core.agent.run_context import ContextWrapper 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 ..computer_client import get_booter, get_local_booter -from .permissions import check_admin_permission +from ..registry import builtin_tool +from .util import check_admin_permission, is_local_runtime, workspace_root + +_COMPUTER_RUNTIME_TOOL_CONFIG = { + "provider_settings.computer_use_runtime": ("local", "sandbox"), +} +@builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG) @dataclass class ExecuteShellTool(FunctionTool): name: str = "astrbot_execute_shell" @@ -38,8 +44,6 @@ class ExecuteShellTool(FunctionTool): }, ) - is_local: bool = False - async def call( self, context: ContextWrapper[AstrAgentContext], @@ -50,15 +54,25 @@ class ExecuteShellTool(FunctionTool): if permission_error := check_admin_permission(context, "Shell execution"): return permission_error - if self.is_local: - sb = get_local_booter() - else: - sb = await get_booter( - context.context.context, - context.context.event.unified_msg_origin, - ) + sb = await get_booter( + context.context.context, + context.context.event.unified_msg_origin, + ) try: - result = await sb.shell.exec(command, background=background, env=env) - return json.dumps(result) + cwd: str | None = None + if is_local_runtime(context): + current_workspace_root = workspace_root( + context.context.event.unified_msg_origin + ) + current_workspace_root.mkdir(parents=True, exist_ok=True) + cwd = str(current_workspace_root) + + result = await sb.shell.exec( + command, + cwd=cwd, + background=background, + env=env, + ) + return json.dumps(result, ensure_ascii=False) except Exception as e: return f"Error executing command: {e!s}" diff --git a/astrbot/core/computer/tools/__init__.py b/astrbot/core/tools/computer_tools/shipyard_neo/__init__.py similarity index 77% rename from astrbot/core/computer/tools/__init__.py rename to astrbot/core/tools/computer_tools/shipyard_neo/__init__.py index 9563f146e..9228c8635 100644 --- a/astrbot/core/computer/tools/__init__.py +++ b/astrbot/core/tools/computer_tools/shipyard_neo/__init__.py @@ -1,5 +1,4 @@ from .browser import BrowserBatchExecTool, BrowserExecTool, RunBrowserSkillTool -from .fs import FileDownloadTool, FileUploadTool from .neo_skills import ( AnnotateExecutionTool, CreateSkillCandidateTool, @@ -13,8 +12,6 @@ from .neo_skills import ( RollbackSkillReleaseTool, SyncSkillReleaseTool, ) -from .python import LocalPythonTool, PythonTool -from .shell import ExecuteShellTool __all__ = [ "AnnotateExecutionTool", @@ -23,16 +20,11 @@ __all__ = [ "CreateSkillCandidateTool", "CreateSkillPayloadTool", "EvaluateSkillCandidateTool", - "ExecuteShellTool", - "FileDownloadTool", - "FileUploadTool", "GetExecutionHistoryTool", "GetSkillPayloadTool", "ListSkillCandidatesTool", "ListSkillReleasesTool", - "LocalPythonTool", "PromoteSkillCandidateTool", - "PythonTool", "RollbackSkillReleaseTool", "RunBrowserSkillTool", "SyncSkillReleaseTool", diff --git a/astrbot/core/computer/tools/browser.py b/astrbot/core/tools/computer_tools/shipyard_neo/browser.py similarity index 98% rename from astrbot/core/computer/tools/browser.py rename to astrbot/core/tools/computer_tools/shipyard_neo/browser.py index 3aace4a3a..c76db4eaf 100644 --- a/astrbot/core/computer/tools/browser.py +++ b/astrbot/core/tools/computer_tools/shipyard_neo/browser.py @@ -8,7 +8,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 .permissions import check_admin_permission +from astrbot.core.tools.computer_tools.util import check_admin_permission def _to_json(data: Any) -> str: diff --git a/astrbot/core/computer/tools/neo_skills.py b/astrbot/core/tools/computer_tools/shipyard_neo/neo_skills.py similarity index 99% rename from astrbot/core/computer/tools/neo_skills.py rename to astrbot/core/tools/computer_tools/shipyard_neo/neo_skills.py index 2cfa647dc..fc0b33bdf 100644 --- a/astrbot/core/computer/tools/neo_skills.py +++ b/astrbot/core/tools/computer_tools/shipyard_neo/neo_skills.py @@ -10,7 +10,7 @@ from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.computer.computer_client import get_booter from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager -from .permissions import check_admin_permission +from astrbot.core.tools.computer_tools.util import check_admin_permission def _to_jsonable(model_like: Any) -> Any: diff --git a/astrbot/core/computer/tools/permissions.py b/astrbot/core/tools/computer_tools/util.py similarity index 53% rename from astrbot/core/computer/tools/permissions.py rename to astrbot/core/tools/computer_tools/util.py index bd1aef4c9..874079bd4 100644 --- a/astrbot/core/computer/tools/permissions.py +++ b/astrbot/core/tools/computer_tools/util.py @@ -1,5 +1,29 @@ +import re +from pathlib import Path + from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.astr_agent_context import AstrAgentContext +from astrbot.core.utils.astrbot_path import get_astrbot_workspaces_path + + +def normalize_umo_for_workspace(umo: str) -> str: + normalized = re.sub(r"[^A-Za-z0-9._-]+", "_", umo.strip()) + return normalized or "unknown" + + +def workspace_root(umo: str) -> Path: + """Root directory for relative paths in local runtime""" + normalized_umo = normalize_umo_for_workspace(umo) + return (Path(get_astrbot_workspaces_path()) / normalized_umo).resolve(strict=False) + + +def is_local_runtime(context: ContextWrapper[AstrAgentContext]) -> bool: + cfg = context.context.context.get_config( + umo=context.context.event.unified_msg_origin + ) + provider_settings = cfg.get("provider_settings", {}) + runtime = str(provider_settings.get("computer_use_runtime", "local")) + return runtime == "local" def check_admin_permission( diff --git a/astrbot/core/tools/knowledge_base_tools.py b/astrbot/core/tools/knowledge_base_tools.py index 94da77404..57c0b0704 100644 --- a/astrbot/core/tools/knowledge_base_tools.py +++ b/astrbot/core/tools/knowledge_base_tools.py @@ -9,6 +9,10 @@ from astrbot.core.knowledge_base.kb_helper import KBHelper from astrbot.core.star.context import Context from astrbot.core.tools.registry import builtin_tool +_KNOWLEDGE_BASE_TOOL_CONFIG = { + "kb_agentic_mode": True, +} + def check_all_kb(kb_list: list[KBHelper | None]) -> bool: """检查是否所有的知识库都为空""" @@ -83,7 +87,7 @@ async def retrieve_knowledge_base( return None -@builtin_tool +@builtin_tool(config=_KNOWLEDGE_BASE_TOOL_CONFIG) @dataclass class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]): name: str = "astr_kb_search" diff --git a/astrbot/core/tools/registry.py b/astrbot/core/tools/registry.py index eaca4af14..c3b10d229 100644 --- a/astrbot/core/tools/registry.py +++ b/astrbot/core/tools/registry.py @@ -1,13 +1,16 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from importlib import import_module -from typing import TypeVar +from typing import Any, TypeVar from astrbot.core.agent.tool import FunctionTool TFunctionTool = TypeVar("TFunctionTool", bound=type[FunctionTool]) _BUILTIN_TOOL_MODULES = ( + "astrbot.core.tools.computer_tools", "astrbot.core.tools.cron_tools", "astrbot.core.tools.knowledge_base_tools", "astrbot.core.tools.message_tools", @@ -17,6 +20,182 @@ _BUILTIN_TOOL_MODULES = ( _builtin_tool_classes_by_name: dict[str, type[FunctionTool]] = {} _builtin_tool_names_by_class: dict[type[FunctionTool], str] = {} _builtin_tools_loaded = False +_MISSING = object() + + +@dataclass(frozen=True) +class BuiltinToolConfigCondition: + key: str + operator: str + expected: Any = None + message: str | None = None + + def evaluate(self, config: dict[str, Any]) -> dict[str, Any]: + actual = _get_config_value(config, self.key) + + if self.operator == "equals": + matched = actual == self.expected + elif self.operator == "in": + expected_values = tuple(self.expected or ()) + matched = actual in expected_values + elif self.operator == "truthy": + matched = bool(actual) + elif self.operator == "custom": + matched = bool(self.expected) + else: + raise ValueError( + f"Unsupported builtin tool config operator: {self.operator}" + ) + + return { + "key": self.key, + "operator": self.operator, + "expected": _json_safe(self.expected), + "actual": _json_safe(None if actual is _MISSING else actual), + "matched": matched, + "message": self.message, + } + + +@dataclass(frozen=True) +class BuiltinToolConfigRule: + conditions: tuple[BuiltinToolConfigCondition, ...] = () + evaluator: Callable[[dict[str, Any]], list[dict[str, Any]]] | None = None + + def evaluate(self, config: dict[str, Any]) -> list[dict[str, Any]]: + if self.evaluator is not None: + return self.evaluator(config) + return [condition.evaluate(config) for condition in self.conditions] + + +def _get_config_value(config: dict[str, Any], key_path: str) -> Any: + current: Any = config + for segment in key_path.split("."): + if not isinstance(current, dict) or segment not in current: + return _MISSING + current = current[segment] + return current + + +def _json_safe(value: Any) -> Any: + if isinstance(value, tuple): + return [_json_safe(item) for item in value] + if isinstance(value, list): + return [_json_safe(item) for item in value] + if isinstance(value, dict): + return {key: _json_safe(val) for key, val in value.items()} + return value + + +def _equals(key: str, expected: Any) -> BuiltinToolConfigCondition: + return BuiltinToolConfigCondition(key=key, operator="equals", expected=expected) + + +def _in(key: str, expected: tuple[Any, ...]) -> BuiltinToolConfigCondition: + return BuiltinToolConfigCondition(key=key, operator="in", expected=expected) + + +def _custom_condition(key: str, *, matched: bool, message: str) -> dict[str, Any]: + return { + "key": key, + "operator": "custom", + "expected": None, + "actual": None, + "matched": matched, + "message": message, + } + + +def _build_rule_from_config_map( + config_map: dict[str, Any], +) -> BuiltinToolConfigRule: + conditions: list[BuiltinToolConfigCondition] = [] + for key, expected in config_map.items(): + if isinstance(expected, tuple): + conditions.append(_in(key, expected)) + else: + conditions.append(_equals(key, expected)) + return BuiltinToolConfigRule(conditions=tuple(conditions)) + + +def _evaluate_send_message_tool(config: dict[str, Any]) -> list[dict[str, Any]]: + platform_configs = config.get("platform", []) + if not isinstance(platform_configs, list): + return [ + _custom_condition( + "platform", + matched=False, + message="No enabled platform in this config supports proactive messaging.", + ) + ] + + for platform_cfg in platform_configs: + if not isinstance(platform_cfg, dict): + continue + if platform_cfg.get("enable", False) is False: + continue + + platform_type = str(platform_cfg.get("type", "")).strip() + platform_id = str(platform_cfg.get("id", "")).strip() or platform_type + if not platform_type: + continue + + if platform_type in {"wecom", "weixin_official_account"}: + continue + + if platform_type == "wecom_ai_bot": + webhook = str(platform_cfg.get("msg_push_webhook_url", "")).strip() + if not webhook: + continue + return [ + _custom_condition( + "platform[].type", + matched=True, + message=( + f"Enabled platform `{platform_id}` uses `wecom_ai_bot`, which supports proactive messaging " + "when `platform[].msg_push_webhook_url` is configured." + ), + ), + BuiltinToolConfigCondition( + key="platform[].msg_push_webhook_url", + operator="truthy", + ).evaluate({"platform[]": {"msg_push_webhook_url": webhook}}), + ] + + return [ + _custom_condition( + "platform[].type", + matched=True, + message=( + f"Enabled platform `{platform_id}` (`{platform_type}`) supports proactive messaging." + ), + ) + ] + + return [ + _custom_condition( + "platform", + matched=False, + message="No enabled platform in this config supports proactive messaging.", + ) + ] + + +_BUILTIN_TOOL_CONFIG_RULES: dict[str, BuiltinToolConfigRule] = {} + + +def _register_builtin_tool_config_rule( + tool_names: tuple[str, ...], + rule: BuiltinToolConfigRule, +) -> None: + for tool_name in tool_names: + _BUILTIN_TOOL_CONFIG_RULES[tool_name] = rule + + +_register_builtin_tool_config_rule( + ("send_message_to_user",), + BuiltinToolConfigRule(evaluator=_evaluate_send_message_tool), +) def _resolve_builtin_tool_name(tool_cls: type[FunctionTool]) -> str: @@ -34,18 +213,29 @@ def _resolve_builtin_tool_name(tool_cls: type[FunctionTool]) -> str: ) -def builtin_tool(tool_cls: TFunctionTool) -> TFunctionTool: - tool_name = _resolve_builtin_tool_name(tool_cls) - existing = _builtin_tool_classes_by_name.get(tool_name) - if existing is not None and existing is not tool_cls: - raise ValueError( - f"Builtin tool name conflict detected: {tool_name} is already registered by " - f"{existing.__module__}.{existing.__name__}.", - ) +def builtin_tool( + tool_cls: TFunctionTool | None = None, + *, + config: dict[str, Any] | None = None, +) -> TFunctionTool | Callable[[TFunctionTool], TFunctionTool]: + def _register(cls: TFunctionTool) -> TFunctionTool: + tool_name = _resolve_builtin_tool_name(cls) + existing = _builtin_tool_classes_by_name.get(tool_name) + if existing is not None and existing is not cls: + raise ValueError( + f"Builtin tool name conflict detected: {tool_name} is already registered by " + f"{existing.__module__}.{existing.__name__}.", + ) - _builtin_tool_classes_by_name[tool_name] = tool_cls - _builtin_tool_names_by_class[tool_cls] = tool_name - return tool_cls + _builtin_tool_classes_by_name[tool_name] = cls + _builtin_tool_names_by_class[cls] = tool_name + if config is not None: + _BUILTIN_TOOL_CONFIG_RULES[tool_name] = _build_rule_from_config_map(config) + return cls + + if tool_cls is None: + return _register + return _register(tool_cls) def ensure_builtin_tools_loaded() -> None: @@ -74,9 +264,64 @@ def iter_builtin_tool_classes() -> tuple[type[FunctionTool], ...]: return tuple(_builtin_tool_classes_by_name.values()) +def get_builtin_tool_config_rule(name: str) -> BuiltinToolConfigRule | None: + ensure_builtin_tools_loaded() + return _BUILTIN_TOOL_CONFIG_RULES.get(name) + + +def get_builtin_tool_config_statuses( + tool_name: str, + config_entries: list[dict[str, Any]], +) -> list[dict[str, Any]]: + rule = get_builtin_tool_config_rule(tool_name) + if rule is None: + return [] + + statuses: list[dict[str, Any]] = [] + for entry in config_entries: + config = entry.get("config") + if not isinstance(config, dict): + continue + + conditions = rule.evaluate(config) + enabled = bool(conditions) and all( + bool(condition.get("matched")) for condition in conditions + ) + statuses.append( + { + "conf_id": entry.get("conf_id"), + "conf_name": entry.get("conf_name"), + "enabled": enabled, + "matched_conditions": [ + condition for condition in conditions if condition.get("matched") + ], + "failed_conditions": [ + condition + for condition in conditions + if not condition.get("matched") + ], + } + ) + return statuses + + +def get_builtin_tool_config_tags( + tool_name: str, + config_entries: list[dict[str, Any]], +) -> list[dict[str, Any]]: + return [ + status + for status in get_builtin_tool_config_statuses(tool_name, config_entries) + if status["enabled"] + ] + + __all__ = [ "builtin_tool", "ensure_builtin_tools_loaded", + "get_builtin_tool_config_rule", + "get_builtin_tool_config_statuses", + "get_builtin_tool_config_tags", "get_builtin_tool_class", "get_builtin_tool_name", "iter_builtin_tool_classes", diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py index f2d595245..bc9d4d7f3 100644 --- a/astrbot/core/tools/web_search_tools.py +++ b/astrbot/core/tools/web_search_tools.py @@ -20,6 +20,22 @@ WEB_SEARCH_TOOL_NAMES = [ "web_search_bocha", "web_search_brave", ] +_TAVILY_WEB_SEARCH_TOOL_CONFIG = { + "provider_settings.web_search": True, + "provider_settings.websearch_provider": "tavily", +} +_BOCHA_WEB_SEARCH_TOOL_CONFIG = { + "provider_settings.web_search": True, + "provider_settings.websearch_provider": "bocha", +} +_BRAVE_WEB_SEARCH_TOOL_CONFIG = { + "provider_settings.web_search": True, + "provider_settings.websearch_provider": "brave", +} +_BAIDU_WEB_SEARCH_TOOL_CONFIG = { + "provider_settings.web_search": True, + "provider_settings.websearch_provider": "baidu_ai_search", +} @std_dataclass @@ -286,7 +302,7 @@ async def _baidu_search( ] -@builtin_tool +@builtin_tool(config=_TAVILY_WEB_SEARCH_TOOL_CONFIG) @pydantic_dataclass class TavilyWebSearchTool(FunctionTool[AstrAgentContext]): name: str = "web_search_tavily" @@ -369,7 +385,7 @@ class TavilyWebSearchTool(FunctionTool[AstrAgentContext]): return _search_result_payload(results) -@builtin_tool +@builtin_tool(config=_TAVILY_WEB_SEARCH_TOOL_CONFIG) @pydantic_dataclass class TavilyExtractWebPageTool(FunctionTool[AstrAgentContext]): name: str = "tavily_extract_web_page" @@ -416,7 +432,7 @@ class TavilyExtractWebPageTool(FunctionTool[AstrAgentContext]): return ret or "Error: Tavily web searcher does not return any results." -@builtin_tool +@builtin_tool(config=_BOCHA_WEB_SEARCH_TOOL_CONFIG) @pydantic_dataclass class BochaWebSearchTool(FunctionTool[AstrAgentContext]): name: str = "web_search_bocha" @@ -480,7 +496,7 @@ class BochaWebSearchTool(FunctionTool[AstrAgentContext]): return _search_result_payload(results) -@builtin_tool +@builtin_tool(config=_BRAVE_WEB_SEARCH_TOOL_CONFIG) @pydantic_dataclass class BraveWebSearchTool(FunctionTool[AstrAgentContext]): name: str = "web_search_brave" @@ -536,7 +552,7 @@ class BraveWebSearchTool(FunctionTool[AstrAgentContext]): return _search_result_payload(results) -@builtin_tool +@builtin_tool(config=_BAIDU_WEB_SEARCH_TOOL_CONFIG) @pydantic_dataclass class BaiduWebSearchTool(FunctionTool[AstrAgentContext]): name: str = "web_search_baidu" diff --git a/astrbot/core/utils/astrbot_path.py b/astrbot/core/utils/astrbot_path.py index 32e4e0468..6b71603a1 100644 --- a/astrbot/core/utils/astrbot_path.py +++ b/astrbot/core/utils/astrbot_path.py @@ -247,3 +247,14 @@ def get_astrbot_knowledge_base_path() -> str: def get_astrbot_backups_path() -> str: """获取Astrbot备份目录路径""" return os.path.realpath(os.path.join(get_astrbot_data_path(), "backups")) + + +def get_astrbot_system_tmp_path() -> str: + """获取Astrbot系统临时目录路径 (/tmp/.astrbot)""" + return "/tmp/.astrbot" + + +def get_astrbot_workspaces_path() -> str: + """获取Astrbot工作区目录路径""" + return os.path.realpath(os.path.join(get_astrbot_data_path(), "workspaces")) + diff --git a/astrbot/core/utils/t2i/template/astrbot_powershell.html b/astrbot/core/utils/t2i/template/astrbot_powershell.html index 9ed3e77a5..746d06fe8 100644 --- a/astrbot/core/utils/t2i/template/astrbot_powershell.html +++ b/astrbot/core/utils/t2i/template/astrbot_powershell.html @@ -2,20 +2,15 @@
-