@@ -38,7 +84,39 @@ const parameterEntries = (tool: ToolItem) => Object.entries(tool.parameters?.pro
>
-
{{ item.name }}
+
+
{{ item.name }}
+
+
+
+ {{ tag.conf_name }}
+
+
+
+
+
+
@@ -135,4 +213,16 @@ const parameterEntries = (tool: ToolItem) => Object.entries(tool.parameters?.pro
font-size: 0.9rem;
line-height: 1.35;
}
+
+.tool-config-tooltip {
+ max-width: 360px;
+ padding: 4px 0;
+ color: rgba(255, 255, 255, 0.92);
+}
+
+.tool-config-tooltip :deep(.text-body-2),
+.tool-config-tooltip :deep(.text-medium-emphasis),
+.tool-config-tooltip :deep(.font-weight-medium) {
+ color: inherit !important;
+}
diff --git a/dashboard/src/components/extension/componentPanel/types.ts b/dashboard/src/components/extension/componentPanel/types.ts
index 1e581c5eca..4784cc8ee4 100644
--- a/dashboard/src/components/extension/componentPanel/types.ts
+++ b/dashboard/src/components/extension/componentPanel/types.ts
@@ -89,6 +89,23 @@ export interface ToolParameter {
description?: string;
}
+export interface ToolConfigCondition {
+ key: string;
+ operator: 'truthy' | 'equals' | 'in' | 'custom' | string;
+ expected?: unknown;
+ actual?: unknown;
+ matched: boolean;
+ message?: string | null;
+}
+
+export interface BuiltinToolConfigTag {
+ conf_id: string;
+ conf_name: string;
+ enabled: boolean;
+ matched_conditions: ToolConfigCondition[];
+ failed_conditions: ToolConfigCondition[];
+}
+
/** MCP/函数工具对象 */
export interface ToolItem {
name: string;
@@ -100,4 +117,6 @@ export interface ToolItem {
};
origin?: string;
origin_name?: string;
+ builtin_config_statuses?: BuiltinToolConfigTag[];
+ builtin_config_tags?: BuiltinToolConfigTag[];
}
diff --git a/dashboard/src/i18n/locales/en-US/features/tool-use.json b/dashboard/src/i18n/locales/en-US/features/tool-use.json
index e0d7620107..36b036f88e 100644
--- a/dashboard/src/i18n/locales/en-US/features/tool-use.json
+++ b/dashboard/src/i18n/locales/en-US/features/tool-use.json
@@ -47,6 +47,15 @@
"originName": "Origin Name",
"readonly": "Read-only",
"actions": "Actions"
+ },
+ "configTags": {
+ "tooltipTitle": "This tool is enabled in config file {config} because:",
+ "conditions": {
+ "truthy": "{key} is enabled",
+ "equals": "{key} = {expected}",
+ "in": "{key} matched {expected}",
+ "fallback": "{key} is currently {actual}"
+ }
}
},
"marketplace": {
diff --git a/dashboard/src/i18n/locales/ru-RU/features/tool-use.json b/dashboard/src/i18n/locales/ru-RU/features/tool-use.json
index b894742356..58181c5e55 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/tool-use.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/tool-use.json
@@ -47,6 +47,15 @@
"originName": "Имя источника",
"readonly": "Только чтение",
"actions": "Действия"
+ },
+ "configTags": {
+ "tooltipTitle": "Этот инструмент включен в файле конфигурации {config}, потому что:",
+ "conditions": {
+ "truthy": "{key} включен",
+ "equals": "{key} = {expected}",
+ "in": "{key} соответствует {expected}",
+ "fallback": "Текущее значение {key}: {actual}"
+ }
}
},
"marketplace": {
diff --git a/dashboard/src/i18n/locales/zh-CN/features/tool-use.json b/dashboard/src/i18n/locales/zh-CN/features/tool-use.json
index 66244947bb..aec4587103 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/tool-use.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/tool-use.json
@@ -47,6 +47,15 @@
"originName": "来源名称",
"readonly": "只读",
"actions": "操作"
+ },
+ "configTags": {
+ "tooltipTitle": "该工具在配置文件 {config} 中启用,因为:",
+ "conditions": {
+ "truthy": "启用了 {key}",
+ "equals": "{key} = {expected}",
+ "in": "{key} 命中了 {expected}",
+ "fallback": "{key} 当前值为 {actual}"
+ }
}
},
"marketplace": {
From 5ca2483a4385fc02c442916f08c57068c436e2da Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Thu, 9 Apr 2026 23:56:28 +0800
Subject: [PATCH 10/20] feat: add tooltip for disabled builtin tools and update
localization strings
---
.../src/components/shared/PersonaForm.vue | 114 +++++++++++++-----
.../components/shared/PersonaQuickPreview.vue | 4 +-
.../i18n/locales/en-US/features/persona.json | 1 +
.../i18n/locales/ru-RU/features/persona.json | 3 +-
.../i18n/locales/zh-CN/features/persona.json | 1 +
5 files changed, 92 insertions(+), 31 deletions(-)
diff --git a/dashboard/src/components/shared/PersonaForm.vue b/dashboard/src/components/shared/PersonaForm.vue
index 19865feb6f..95aa77f666 100644
--- a/dashboard/src/components/shared/PersonaForm.vue
+++ b/dashboard/src/components/shared/PersonaForm.vue
@@ -90,31 +90,52 @@
@@ -155,11 +176,26 @@
-
- {{ toolName }}
-
+
+
+
+ {{ toolName }}
+
+
+ {{ tm('form.builtinToolDisabledHint') }}
+
{{ tm('form.noToolsSelected') }}
@@ -712,6 +748,9 @@ export default {
},
toggleTool(toolName) {
+ if (this.isBuiltinToolName(toolName)) {
+ return;
+ }
// 如果当前是全选状态,需要先转换为具体的工具列表
if (this.personaForm.tools === null) {
// 如果是全选状态,点击某个工具表示要取消选择该工具
@@ -735,6 +774,9 @@ export default {
},
removeTool(toolName) {
+ if (this.isBuiltinToolName(toolName)) {
+ return;
+ }
// 如果当前是全选状态,需要先转换为具体的工具列表
if (this.personaForm.tools === null) {
// 创建一个包含所有工具的数组,然后移除指定工具
@@ -784,6 +826,14 @@ export default {
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
},
+ isBuiltinTool(tool) {
+ return tool?.origin === 'builtin' || tool?.readonly === true;
+ },
+
+ isBuiltinToolName(toolName) {
+ return this.availableTools.some(tool => tool.name === toolName && this.isBuiltinTool(tool));
+ },
+
getDialogRules(index) {
const dialogType = index % 2 === 0 ? this.tm('form.userMessage') : this.tm('form.assistantMessage');
return [
@@ -859,6 +909,12 @@ export default {
overflow-y: auto;
}
+.builtin-tool-checkbox-placeholder {
+ width: 40px;
+ height: 40px;
+ flex: 0 0 40px;
+}
+
.skills-selection {
max-height: 300px;
overflow-y: auto;
diff --git a/dashboard/src/components/shared/PersonaQuickPreview.vue b/dashboard/src/components/shared/PersonaQuickPreview.vue
index c8ce6d51a1..a15ff84ecb 100644
--- a/dashboard/src/components/shared/PersonaQuickPreview.vue
+++ b/dashboard/src/components/shared/PersonaQuickPreview.vue
@@ -117,7 +117,9 @@ const defaultPersonaData = {
const normalizedTools = computed(() => (Array.isArray(personaData.value?.tools) ? personaData.value.tools : []))
const normalizedSkills = computed(() => (Array.isArray(personaData.value?.skills) ? personaData.value.skills : []))
-const allToolsCount = computed(() => Object.keys(toolMetaMap.value).length)
+const allToolsCount = computed(() =>
+ Object.values(toolMetaMap.value).filter((tool) => tool.origin !== 'builtin').length
+)
const allSkillsCount = computed(() => availableSkills.value.length)
const resolvedTools = computed(() =>
normalizedTools.value.map((toolName) => {
diff --git a/dashboard/src/i18n/locales/en-US/features/persona.json b/dashboard/src/i18n/locales/en-US/features/persona.json
index 84aaef52c6..3dc865c025 100644
--- a/dashboard/src/i18n/locales/en-US/features/persona.json
+++ b/dashboard/src/i18n/locales/en-US/features/persona.json
@@ -35,6 +35,7 @@
"mcpServersQuickSelect": "MCP Servers Quick Select",
"searchTools": "Search Tools",
"selectedTools": "Selected Tools",
+ "builtinToolDisabledHint": "Builtin tools cannot be enabled or disabled here yet. Please enable or disable the corresponding config items in the config file.",
"noToolsAvailable": "No tools available",
"noToolsFound": "No matching tools found",
"loadingTools": "Loading tools...",
diff --git a/dashboard/src/i18n/locales/ru-RU/features/persona.json b/dashboard/src/i18n/locales/ru-RU/features/persona.json
index e6e58ad7fa..f7e0aff25a 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/persona.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/persona.json
@@ -35,6 +35,7 @@
"mcpServersQuickSelect": "Быстрый выбор MCP серверов",
"searchTools": "Поиск инструментов",
"selectedTools": "Выбранные инструменты",
+ "builtinToolDisabledHint": "Встроенные инструменты пока нельзя включать или выключать здесь. Измените соответствующие параметры в файле конфигурации.",
"noToolsAvailable": "Нет доступных инструментов",
"noToolsFound": "Инструменты не найдены",
"loadingTools": "Загрузка инструментов...",
@@ -143,4 +144,4 @@
"success": "Объект перемещен",
"error": "Ошибка перемещения"
}
-}
\ No newline at end of file
+}
diff --git a/dashboard/src/i18n/locales/zh-CN/features/persona.json b/dashboard/src/i18n/locales/zh-CN/features/persona.json
index d3eec49a57..e707a5d621 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/persona.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/persona.json
@@ -35,6 +35,7 @@
"mcpServersQuickSelect": "MCP 服务器快速选择",
"searchTools": "搜索工具",
"selectedTools": "已选择的工具",
+ "builtinToolDisabledHint": "暂不支持在这里启用和停用内置工具,请在配置文件中启用和停用工具对应的配置项。",
"noToolsAvailable": "暂无可用工具",
"noToolsFound": "未找到匹配的工具",
"loadingTools": "正在加载工具...",
From add5db67480b1b8744b01c3d4aed836226381267 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Fri, 10 Apr 2026 00:01:50 +0800
Subject: [PATCH 11/20] feat: add workspace extra prompt handling in message
processing
---
astrbot/core/astr_main_agent.py | 48 ++++++++++++++++++++++++++++-----
1 file changed, 42 insertions(+), 6 deletions(-)
diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
index 4a9e2637d0..bf8bd12b55 100644
--- a/astrbot/core/astr_main_agent.py
+++ b/astrbot/core/astr_main_agent.py
@@ -9,6 +9,7 @@
import zoneinfo
from collections.abc import Coroutine
from dataclasses import dataclass, field
+from pathlib import Path
from astrbot.core import logger
from astrbot.core.agent.handoff import HandoffTool
@@ -64,6 +65,7 @@
RollbackSkillReleaseTool,
RunBrowserSkillTool,
SyncSkillReleaseTool,
+ _normalize_umo_for_workspace,
)
from astrbot.core.tools.cron_tools import (
CreateActiveCronTool,
@@ -301,6 +303,44 @@ def _apply_prompt_prefix(req: ProviderRequest, cfg: dict) -> None:
req.prompt = f"{prefix}{req.prompt}"
+def _get_workspace_path_for_umo(umo: str) -> Path:
+ normalized_umo = _normalize_umo_for_workspace(umo)
+ return Path(get_astrbot_workspaces_path()) / normalized_umo
+
+
+def _apply_workspace_extra_prompt(
+ event: AstrMessageEvent,
+ req: ProviderRequest,
+) -> None:
+ extra_prompt_path = _get_workspace_path_for_umo(event.unified_msg_origin) / (
+ "EXTRA_PROMPT.md"
+ )
+ if not extra_prompt_path.is_file():
+ return
+
+ try:
+ extra_prompt = extra_prompt_path.read_text(encoding="utf-8").strip()
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ "Failed to read workspace extra prompt for umo=%s from %s: %s",
+ event.unified_msg_origin,
+ extra_prompt_path,
+ exc,
+ )
+ return
+
+ if not extra_prompt:
+ return
+
+ req.system_prompt = (
+ f"{req.system_prompt or ''}\n"
+ "[Workspace Extra Prompt]\n"
+ "The following instructions are loaded from the current workspace "
+ "`EXTRA_PROMPT.md` file.\n"
+ f"{extra_prompt}\n"
+ )
+
+
def _apply_local_env_tools(req: ProviderRequest, plugin_context: Context) -> None:
if req.func_tool is None:
req.func_tool = ToolSet()
@@ -781,6 +821,7 @@ async def _decorate_llm_request(
if tz is None:
tz = plugin_context.get_config().get("timezone")
_append_system_reminders(event, req, cfg, tz)
+ _apply_workspace_extra_prompt(event, req)
def _modalities_fix(provider: Provider, req: ProviderRequest) -> None:
@@ -1404,14 +1445,9 @@ async def build_main_agent(
)
if config.computer_use_runtime == "local":
- from astrbot.core.tools.computer_tools.fs import (
- _normalize_umo_for_workspace,
- )
-
- normalized_umo = _normalize_umo_for_workspace(event.unified_msg_origin)
tool_prompt += (
f"\nCurrent workspace you can use: "
- f"`{os.path.join(get_astrbot_workspaces_path(), normalized_umo)}`\n"
+ f"`{_get_workspace_path_for_umo(event.unified_msg_origin)}`\n"
"Unless the user explicitly specifies a different directory, "
"perform all file-related operations in this workspace.\n"
)
From 5f049f2bb52237d4c15e0cbd64051de58b5af7ac Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Fri, 10 Apr 2026 11:35:59 +0800
Subject: [PATCH 12/20] feat: add ripgrep installation to Dockerfile
---
Dockerfile | 1 +
1 file changed, 1 insertion(+)
diff --git a/Dockerfile b/Dockerfile
index 7bfb00c7e2..30977605c6 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 \
From 7bf1d19332690da4e316a6dd4a80d8a0e41317ab Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Fri, 10 Apr 2026 15:38:40 +0800
Subject: [PATCH 13/20] perf: shell executed in workspace dir in local env
---
astrbot/core/astr_main_agent.py | 4 +-
astrbot/core/tools/computer_tools/__init__.py | 5 +-
astrbot/core/tools/computer_tools/fs.py | 49 ++++++-------------
astrbot/core/tools/computer_tools/python.py | 2 +-
astrbot/core/tools/computer_tools/shell.py | 17 ++++++-
.../computer_tools/shipyard_neo/browser.py | 2 +-
.../computer_tools/shipyard_neo/neo_skills.py | 2 +-
.../{permissions.py => util.py} | 24 +++++++++
8 files changed, 62 insertions(+), 43 deletions(-)
rename astrbot/core/tools/computer_tools/{permissions.py => util.py} (52%)
diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
index bf8bd12b55..7e4b5379e6 100644
--- a/astrbot/core/astr_main_agent.py
+++ b/astrbot/core/astr_main_agent.py
@@ -65,7 +65,7 @@
RollbackSkillReleaseTool,
RunBrowserSkillTool,
SyncSkillReleaseTool,
- _normalize_umo_for_workspace,
+ normalize_umo_for_workspace,
)
from astrbot.core.tools.cron_tools import (
CreateActiveCronTool,
@@ -304,7 +304,7 @@ def _apply_prompt_prefix(req: ProviderRequest, cfg: dict) -> None:
def _get_workspace_path_for_umo(umo: str) -> Path:
- normalized_umo = _normalize_umo_for_workspace(umo)
+ normalized_umo = normalize_umo_for_workspace(umo)
return Path(get_astrbot_workspaces_path()) / normalized_umo
diff --git a/astrbot/core/tools/computer_tools/__init__.py b/astrbot/core/tools/computer_tools/__init__.py
index a147ccbcd0..7e364ffd23 100644
--- a/astrbot/core/tools/computer_tools/__init__.py
+++ b/astrbot/core/tools/computer_tools/__init__.py
@@ -5,9 +5,7 @@
FileUploadTool,
FileWriteTool,
GrepTool,
- _normalize_umo_for_workspace,
)
-from .permissions import check_admin_permission
from .python import LocalPythonTool, PythonTool
from .shell import ExecuteShellTool
from .shipyard_neo import (
@@ -26,6 +24,7 @@
RunBrowserSkillTool,
SyncSkillReleaseTool,
)
+from .util import check_admin_permission, normalize_umo_for_workspace
__all__ = [
"AnnotateExecutionTool",
@@ -51,6 +50,6 @@
"RollbackSkillReleaseTool",
"RunBrowserSkillTool",
"SyncSkillReleaseTool",
- "_normalize_umo_for_workspace",
+ "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
index 8b513913c6..94f987fcdc 100644
--- a/astrbot/core/tools/computer_tools/fs.py
+++ b/astrbot/core/tools/computer_tools/fs.py
@@ -32,7 +32,6 @@
"""
import os
-import re
import uuid
from dataclasses import dataclass, field
from pathlib import Path
@@ -48,11 +47,15 @@
from astrbot.core.utils.astrbot_path import (
get_astrbot_skills_path,
get_astrbot_temp_path,
- get_astrbot_workspaces_path,
)
from ..registry import builtin_tool
-from .permissions import check_admin_permission
+from .util import (
+ check_admin_permission,
+ is_local_runtime,
+ normalize_umo_for_workspace,
+ workspace_root,
+)
_COMPUTER_RUNTIME_TOOL_CONFIG = {
"provider_settings.computer_use_runtime": ("local", "sandbox"),
@@ -62,14 +65,9 @@
}
-def _normalize_umo_for_workspace(umo: str) -> str:
- normalized = re.sub(r"[^A-Za-z0-9._-]+", "_", umo.strip())
- return normalized or "unknown"
-
-
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)
+ normalized_umo = normalize_umo_for_workspace(umo)
return [
"data/skills",
f"data/workspaces/{normalized_umo}",
@@ -77,32 +75,17 @@ def _restricted_env_path_labels(umo: str) -> list[str]:
]
-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 _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),
+ workspace_root(umo),
Path("/tmp/.astrbot").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 _is_restricted_env(context: ContextWrapper[AstrAgentContext]) -> bool:
- if not _is_local_runtime(context):
+ if not is_local_runtime(context):
return False
cfg = context.context.context.get_config(
umo=context.context.event.unified_msg_origin
@@ -120,7 +103,7 @@ def _resolve_tool_path(path: str, *, local_env: bool, umo: str) -> str:
if candidate.is_absolute():
return str(candidate.resolve(strict=False))
if local_env:
- return str((_workspace_root(umo) / candidate).resolve(strict=False))
+ return str((workspace_root(umo) / candidate).resolve(strict=False))
return normalized_path
@@ -129,7 +112,7 @@ def _resolve_user_path(path: str, *, local_env: bool, umo: str) -> Path:
if candidate.is_absolute():
return candidate.resolve(strict=False)
if local_env:
- return (_workspace_root(umo) / candidate).resolve(strict=False)
+ return (workspace_root(umo) / candidate).resolve(strict=False)
return (Path.cwd() / candidate).resolve(strict=False)
@@ -216,7 +199,7 @@ async def call(
offset: int | None = None,
limit: int | None = None,
) -> ToolExecResult:
- local_env = _is_local_runtime(context)
+ local_env = is_local_runtime(context)
restricted = _is_restricted_env(context)
try:
normalized_path = (
@@ -278,7 +261,7 @@ async def call(
path: str,
content: str,
) -> ToolExecResult:
- local_env = _is_local_runtime(context)
+ local_env = is_local_runtime(context)
restricted = _is_restricted_env(context)
try:
normalized_path = (
@@ -356,7 +339,7 @@ async def call(
replace_all: bool = False,
) -> ToolExecResult:
umo = str(context.context.event.unified_msg_origin)
- local_env = _is_local_runtime(context)
+ local_env = is_local_runtime(context)
restricted = _is_restricted_env(context)
try:
normalized_path = (
@@ -522,7 +505,7 @@ def _normalize_search_paths(
if restricted:
return [str(root) for root in _read_allowed_roots(umo)]
if local_env:
- return [str(_workspace_root(umo))]
+ return [str(workspace_root(umo))]
return ["."]
if restricted:
@@ -554,7 +537,7 @@ async def call(
if not normalized_pattern:
return "Error: `pattern` must be a non-empty string."
- local_env = _is_local_runtime(context)
+ local_env = is_local_runtime(context)
restricted = _is_restricted_env(context)
try:
search_paths = (
diff --git a/astrbot/core/tools/computer_tools/python.py b/astrbot/core/tools/computer_tools/python.py
index 1be2a6e1b5..e0bb6c9de6 100644
--- a/astrbot/core/tools/computer_tools/python.py
+++ b/astrbot/core/tools/computer_tools/python.py
@@ -11,7 +11,7 @@
from astrbot.core.message.message_event_result import MessageChain
from ..registry import builtin_tool
-from .permissions import check_admin_permission
+from .util import check_admin_permission
_OS_NAME = platform.system()
_SANDBOX_PYTHON_TOOL_CONFIG = {
diff --git a/astrbot/core/tools/computer_tools/shell.py b/astrbot/core/tools/computer_tools/shell.py
index 98343bc1ca..2d3997387c 100644
--- a/astrbot/core/tools/computer_tools/shell.py
+++ b/astrbot/core/tools/computer_tools/shell.py
@@ -8,7 +8,7 @@
from astrbot.core.computer.computer_client import get_booter
from ..registry import builtin_tool
-from .permissions import check_admin_permission
+from .util import check_admin_permission, is_local_runtime, workspace_root
_COMPUTER_RUNTIME_TOOL_CONFIG = {
"provider_settings.computer_use_runtime": ("local", "sandbox"),
@@ -59,7 +59,20 @@ async def call(
context.context.event.unified_msg_origin,
)
try:
- result = await sb.shell.exec(command, background=background, env=env)
+ 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)
except Exception as e:
return f"Error executing command: {str(e)}"
diff --git a/astrbot/core/tools/computer_tools/shipyard_neo/browser.py b/astrbot/core/tools/computer_tools/shipyard_neo/browser.py
index 72aa6076d1..b4b7f4fd06 100644
--- a/astrbot/core/tools/computer_tools/shipyard_neo/browser.py
+++ b/astrbot/core/tools/computer_tools/shipyard_neo/browser.py
@@ -7,7 +7,7 @@
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.computer_client import get_booter
-from astrbot.core.tools.computer_tools.permissions import check_admin_permission
+from astrbot.core.tools.computer_tools.util import check_admin_permission
from astrbot.core.tools.registry import builtin_tool
_SHIPYARD_NEO_TOOL_CONFIG = {
diff --git a/astrbot/core/tools/computer_tools/shipyard_neo/neo_skills.py b/astrbot/core/tools/computer_tools/shipyard_neo/neo_skills.py
index edbe1ef6fd..e2c4f59093 100644
--- a/astrbot/core/tools/computer_tools/shipyard_neo/neo_skills.py
+++ b/astrbot/core/tools/computer_tools/shipyard_neo/neo_skills.py
@@ -9,7 +9,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 astrbot.core.tools.computer_tools.permissions import check_admin_permission
+from astrbot.core.tools.computer_tools.util import check_admin_permission
from astrbot.core.tools.registry import builtin_tool
_SHIPYARD_NEO_TOOL_CONFIG = {
diff --git a/astrbot/core/tools/computer_tools/permissions.py b/astrbot/core/tools/computer_tools/util.py
similarity index 52%
rename from astrbot/core/tools/computer_tools/permissions.py
rename to astrbot/core/tools/computer_tools/util.py
index 489f485f9d..a3930b4c6a 100644
--- a/astrbot/core/tools/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(
From cff148860afb96b1baf0fe59d9ef9864fabe80e1 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Fri, 10 Apr 2026 16:31:53 +0800
Subject: [PATCH 14/20] feat: enhance file reading capabilities to support PDF
and DOCX parsing, including workspace storage for long documents
---
astrbot/core/computer/file_read_utils.py | 226 ++++++++++++++++++++++-
astrbot/core/tools/computer_tools/fs.py | 26 ++-
tests/test_computer_fs_tools.py | 70 ++++++-
3 files changed, 311 insertions(+), 11 deletions(-)
diff --git a/astrbot/core/computer/file_read_utils.py b/astrbot/core/computer/file_read_utils.py
index 3c553434a3..31b9e5ea05 100644
--- a/astrbot/core/computer/file_read_utils.py
+++ b/astrbot/core/computer/file_read_utils.py
@@ -1,7 +1,10 @@
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
@@ -45,11 +48,13 @@
b"\xff\xfe\x00\x00",
b"\x00\x00\xfe\xff",
)
-_BINARY_MAGIC_PREFIXES = (
- b"%PDF-",
+_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",
@@ -66,6 +71,13 @@ class FileProbe:
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
@@ -269,6 +281,10 @@ def _run() -> dict[str, str | int]:
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())
@@ -320,6 +336,63 @@ 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(
@@ -375,6 +448,10 @@ def _validate_text_output(content: str) -> str | None:
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 (
@@ -385,6 +462,135 @@ def _validate_full_text_read_request(probe: FileProbe) -> str | None:
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 that text file with a narrower `offset`/`limit` window."
+ )
+
+ if selection_returned:
+ return (
+ "Full converted text is also available at "
+ f"`{converted_text_path}`. Use that file with a narrow `offset`/`limit` "
+ "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 that text file with a narrow `offset`/`limit` 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,
*,
@@ -392,6 +598,7 @@ async def read_file_tool_result(
path: str,
offset: int | None,
limit: int | None,
+ workspace_dir: str | None = None,
) -> ToolExecResult:
if local_mode:
probe_payload = await _probe_local_file(path)
@@ -406,6 +613,21 @@ async def read_file_tool_result(
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."
diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py
index 94f987fcdc..d18701f7b4 100644
--- a/astrbot/core/tools/computer_tools/fs.py
+++ b/astrbot/core/tools/computer_tools/fs.py
@@ -50,11 +50,11 @@
)
from ..registry import builtin_tool
+from . import util as computer_util
from .util import (
check_admin_permission,
is_local_runtime,
normalize_umo_for_workspace,
- workspace_root,
)
_COMPUTER_RUNTIME_TOOL_CONFIG = {
@@ -75,11 +75,22 @@ def _restricted_env_path_labels(umo: str) -> list[str]:
]
+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),
+ _workspace_root(umo),
Path("/tmp/.astrbot").resolve(strict=False),
)
@@ -103,7 +114,7 @@ def _resolve_tool_path(path: str, *, local_env: bool, umo: str) -> str:
if candidate.is_absolute():
return str(candidate.resolve(strict=False))
if local_env:
- return str((workspace_root(umo) / candidate).resolve(strict=False))
+ return str((_workspace_root(umo) / candidate).resolve(strict=False))
return normalized_path
@@ -112,7 +123,7 @@ def _resolve_user_path(path: str, *, local_env: bool, umo: str) -> Path:
if candidate.is_absolute():
return candidate.resolve(strict=False)
if local_env:
- return (workspace_root(umo) / candidate).resolve(strict=False)
+ return (_workspace_root(umo) / candidate).resolve(strict=False)
return (Path.cwd() / candidate).resolve(strict=False)
@@ -225,6 +236,11 @@ async def call(
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}"
@@ -505,7 +521,7 @@ def _normalize_search_paths(
if restricted:
return [str(root) for root in _read_allowed_roots(umo)]
if local_env:
- return [str(workspace_root(umo))]
+ return [str(_workspace_root(umo))]
return ["."]
if restricted:
diff --git a/tests/test_computer_fs_tools.py b/tests/test_computer_fs_tools.py
index 6e718dfbd6..aaaed11a91 100644
--- a/tests/test_computer_fs_tools.py
+++ b/tests/test_computer_fs_tools.py
@@ -1,6 +1,8 @@
from __future__ import annotations
import base64
+import io
+import zipfile
from types import SimpleNamespace
from typing import Any
@@ -12,6 +14,7 @@
from astrbot.core.computer import file_read_utils
from astrbot.core.computer.booters.local import LocalBooter
from astrbot.core.tools.computer_tools import fs as fs_tools
+from astrbot.core.tools.computer_tools import util as computer_util
def _make_context(
@@ -52,7 +55,7 @@ def _setup_local_fs_tools(
temp_root.mkdir()
monkeypatch.setattr(
- fs_tools,
+ computer_util,
"get_astrbot_workspaces_path",
lambda: str(workspaces_root),
)
@@ -79,7 +82,7 @@ async def _fake_get_booter(_ctx, _umo):
monkeypatch.setattr(fs_tools, "get_booter", _fake_get_booter)
- normalized_umo = fs_tools._normalize_umo_for_workspace(umo)
+ normalized_umo = computer_util.normalize_umo_for_workspace(umo)
workspace = workspaces_root / normalized_umo
workspace.mkdir(parents=True, exist_ok=True)
return workspace
@@ -174,7 +177,7 @@ async def test_file_read_tool_treats_svg_as_text(
@pytest.mark.asyncio
-async def test_file_read_tool_rejects_pdf_as_binary(
+async def test_file_read_tool_reads_pdf_via_parser(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
):
@@ -182,12 +185,71 @@ async def test_file_read_tool_rejects_pdf_as_binary(
pdf_path = workspace / "doc.pdf"
pdf_path.write_bytes(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<<>>\nendobj\n")
+ async def _fake_parse_pdf(_file_bytes: bytes, _file_name: str) -> str:
+ return "page-1\npage-2\n"
+
+ monkeypatch.setattr(file_read_utils, "_parse_local_pdf_text", _fake_parse_pdf)
+
result = await fs_tools.FileReadTool().call(
_make_context(),
path="doc.pdf",
)
- assert result == "Error reading file: binary files are not supported by this tool."
+ assert result == "page-1\npage-2\n"
+
+
+@pytest.mark.asyncio
+async def test_file_read_tool_reads_docx_via_parser_and_magic(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path,
+):
+ workspace = _setup_local_fs_tools(monkeypatch, tmp_path)
+ docx_path = workspace / "report.bin"
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, mode="w") as archive:
+ archive.writestr("[Content_Types].xml", "")
+ archive.writestr("word/document.xml", "")
+ docx_path.write_bytes(buffer.getvalue())
+
+ async def _fake_parse_docx(_file_bytes: bytes, _file_name: str) -> str:
+ return "doc-line-1\ndoc-line-2\n"
+
+ monkeypatch.setattr(file_read_utils, "_parse_local_docx_text", _fake_parse_docx)
+
+ result = await fs_tools.FileReadTool().call(
+ _make_context(),
+ path="report.bin",
+ )
+
+ assert result == "doc-line-1\ndoc-line-2\n"
+
+
+@pytest.mark.asyncio
+async def test_file_read_tool_stores_long_converted_document_in_workspace(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path,
+):
+ workspace = _setup_local_fs_tools(monkeypatch, tmp_path)
+ pdf_path = workspace / "manual.pdf"
+ pdf_path.write_bytes(b"%PDF-1.7\nfake\n")
+ long_text = _make_large_text()
+
+ async def _fake_parse_pdf(_file_bytes: bytes, _file_name: str) -> str:
+ return long_text
+
+ monkeypatch.setattr(file_read_utils, "_parse_local_pdf_text", _fake_parse_pdf)
+
+ result = await fs_tools.FileReadTool().call(
+ _make_context(),
+ path="manual.pdf",
+ )
+
+ converted_root = workspace / "converted_files"
+ converted_files = list(converted_root.glob("manual.pdf_*/text.txt"))
+ assert len(converted_files) == 1
+ assert converted_files[0].read_text(encoding="utf-8") == long_text
+ assert str(converted_files[0]) in result
+ assert "narrow `offset`/`limit` window" in result
@pytest.mark.asyncio
From 3acda6f77a8d0483b2f494483e1bdfe4071337b8 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Fri, 10 Apr 2026 16:46:13 +0800
Subject: [PATCH 15/20] feat: update converted text notice to suggest using
grep for large files
---
astrbot/core/computer/file_read_utils.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/astrbot/core/computer/file_read_utils.py b/astrbot/core/computer/file_read_utils.py
index 31b9e5ea05..52a83ceb85 100644
--- a/astrbot/core/computer/file_read_utils.py
+++ b/astrbot/core/computer/file_read_utils.py
@@ -508,20 +508,20 @@ def _build_converted_text_notice(
return (
"Converted text was saved to "
f"`{converted_text_path}`. The requested output is still too large to "
- "return directly. Read that text file with a narrower `offset`/`limit` window."
+ "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}`. Use that file with a narrow `offset`/`limit` "
+ 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 that text file with a narrow `offset`/`limit` window."
+ "return directly. Read or grep that file with a narrow window."
)
From 1745e9c4fb6c30a8d2bdf17583a9208f5d510b24 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Fri, 10 Apr 2026 17:26:17 +0800
Subject: [PATCH 16/20] feat: implement handling for large tool results with
overflow file writing and read tool integration
---
.../agent/runners/tool_loop_agent_runner.py | 122 ++++++++++++-
astrbot/core/astr_main_agent.py | 13 +-
astrbot/core/computer/file_read_utils.py | 18 +-
astrbot/core/star/context.py | 8 +
astrbot/core/tools/computer_tools/fs.py | 5 +-
astrbot/core/utils/astrbot_path.py | 61 ++++---
tests/test_computer_fs_tools.py | 8 +-
tests/test_tool_loop_agent_runner.py | 167 +++++++++++++++++-
8 files changed, 356 insertions(+), 46 deletions(-)
diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py
index 9d0b0ffce1..5b952d545d 100644
--- a/astrbot/core/agent/runners/tool_loop_agent_runner.py
+++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py
@@ -4,9 +4,11 @@
import time
import traceback
import typing as T
+import uuid
from collections.abc import AsyncIterator
from contextlib import suppress
from dataclasses import dataclass, field
+from pathlib import Path
from mcp.types import (
BlobResourceContents,
@@ -25,7 +27,7 @@
from astrbot import logger
from astrbot.core.agent.message import ImageURLPart, TextPart, ThinkPart
-from astrbot.core.agent.tool import ToolSet
+from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.agent.tool_image_cache import tool_image_cache
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.message.components import Json
@@ -45,7 +47,7 @@
from ..context.compressor import ContextCompressor
from ..context.config import ContextConfig
from ..context.manager import ContextManager
-from ..context.token_counter import TokenCounter
+from ..context.token_counter import EstimateTokenCounter, TokenCounter
from ..hooks import BaseAgentRunHooks
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
from ..response import AgentResponseData, AgentStats
@@ -97,6 +99,8 @@ class _ToolExecutionInterrupted(Exception):
class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
+ TOOL_RESULT_MAX_ESTIMATED_TOKENS = 27_500
+ TOOL_RESULT_PREVIEW_MAX_ESTIMATED_TOKENS = 7000
EMPTY_OUTPUT_RETRY_ATTEMPTS = 3
EMPTY_OUTPUT_RETRY_WAIT_MIN_S = 1
EMPTY_OUTPUT_RETRY_WAIT_MAX_S = 4
@@ -151,6 +155,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
"Otherwise, change strategy, adjust arguments, or explain the limitation "
"to the user."
)
+ TOOL_RESULT_OVERFLOW_NOTICE_TEMPLATE = (
+ "Truncated tool output preview shown above. "
+ "The tool output was too large to include directly and was written to "
+ "`{overflow_path}`. Use {read_tool_hint} with a narrower window to inspect it."
+ )
def _get_persona_custom_error_message(self) -> str | None:
"""Read persona-level custom error message from event extras when available."""
@@ -206,6 +215,8 @@ async def reset(
custom_compressor: ContextCompressor | None = None,
tool_schema_mode: str | None = "full",
fallback_providers: list[Provider] | None = None,
+ tool_result_overflow_dir: str | None = None,
+ read_tool: FunctionTool | None = None,
**kwargs: T.Any,
) -> None:
self.req = request
@@ -217,6 +228,9 @@ async def reset(
self.truncate_turns = truncate_turns
self.custom_token_counter = custom_token_counter
self.custom_compressor = custom_compressor
+ self.tool_result_overflow_dir = tool_result_overflow_dir
+ self.read_tool = read_tool
+ self._tool_result_token_counter = EstimateTokenCounter()
# we will do compress when:
# 1. before requesting LLM
# TODO: 2. after LLM output a tool call
@@ -298,6 +312,103 @@ async def reset(
self.stats = AgentStats()
self.stats.start_time = time.time()
+ def _read_tool_hint(self) -> str:
+ if self.read_tool is not None:
+ return f"`{self.read_tool.name}`"
+ return "the available file-read tool"
+
+ async def _write_tool_result_overflow_file(
+ self,
+ *,
+ tool_call_id: str,
+ content: str,
+ ) -> str:
+ if self.tool_result_overflow_dir is None:
+ raise ValueError("tool_result_overflow_dir is not configured")
+
+ overflow_dir = Path(self.tool_result_overflow_dir).resolve(strict=False)
+ safe_tool_call_id = (
+ "".join(
+ ch if ch.isalnum() or ch in {"-", "_", "."} else "_"
+ for ch in tool_call_id
+ ).strip("._")
+ or "tool_call"
+ )
+ file_name = f"{safe_tool_call_id}_{uuid.uuid4().hex[:8]}.txt"
+ overflow_path = overflow_dir / file_name
+
+ def _run() -> str:
+ overflow_dir.mkdir(parents=True, exist_ok=True)
+ overflow_path.write_text(content, encoding="utf-8")
+ return str(overflow_path)
+
+ return await asyncio.to_thread(_run)
+
+ async def _materialize_large_tool_result(
+ self,
+ *,
+ tool_call_id: str,
+ content: str,
+ ) -> str:
+ if self.tool_result_overflow_dir is None or self.read_tool is None:
+ return content
+
+ estimated_tokens = self._tool_result_token_counter.count_tokens(
+ [Message(role="tool", content=content, tool_call_id=tool_call_id)]
+ )
+ if estimated_tokens <= self.TOOL_RESULT_MAX_ESTIMATED_TOKENS:
+ return content
+
+ preview = self._truncate_tool_result_preview(content, tool_call_id=tool_call_id)
+ try:
+ overflow_path = await self._write_tool_result_overflow_file(
+ tool_call_id=tool_call_id,
+ content=content,
+ )
+ except Exception as exc:
+ logger.warning(
+ "Failed to spill oversized tool result for %s: %s",
+ tool_call_id,
+ exc,
+ exc_info=True,
+ )
+ error_notice = (
+ "Tool output exceeded the inline result limit "
+ f"({estimated_tokens} estimated tokens > "
+ f"{self.TOOL_RESULT_MAX_ESTIMATED_TOKENS}) and could not be written "
+ f"to `{self.tool_result_overflow_dir}`: {exc}"
+ )
+ if not preview:
+ return error_notice
+ return f"{preview}\n\n{error_notice}"
+
+ notice = self.TOOL_RESULT_OVERFLOW_NOTICE_TEMPLATE.format(
+ overflow_path=overflow_path,
+ read_tool_hint=self._read_tool_hint(),
+ )
+ if not preview:
+ return notice
+ return f"{preview}\n\n{notice}"
+
+ def _truncate_tool_result_preview(
+ self,
+ content: str,
+ *,
+ tool_call_id: str,
+ ) -> str:
+ preview = content
+ while preview:
+ estimated_tokens = self._tool_result_token_counter.count_tokens(
+ [Message(role="tool", content=preview, tool_call_id=tool_call_id)]
+ )
+ if estimated_tokens <= self.TOOL_RESULT_PREVIEW_MAX_ESTIMATED_TOKENS:
+ return preview
+ next_len = len(preview) // 2
+ if next_len <= 0:
+ break
+ preview = preview[:next_len]
+ return preview
+
async def _iter_llm_responses(
self, *, include_model: bool = True
) -> T.AsyncGenerator[LLMResponse, None]:
@@ -933,9 +1044,14 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
"The tool has returned a data type that is not supported."
)
if result_parts:
+ inline_result = "\n\n".join(result_parts)
+ inline_result = await self._materialize_large_tool_result(
+ tool_call_id=func_tool_id,
+ content=inline_result,
+ )
_append_tool_call_result(
func_tool_id,
- "\n\n".join(result_parts)
+ inline_result
+ self._build_repeated_tool_call_guidance(
func_tool_name, tool_call_streak
),
diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
index ffe93aa6ea..0b74d63c6d 100644
--- a/astrbot/core/astr_main_agent.py
+++ b/astrbot/core/astr_main_agent.py
@@ -81,7 +81,10 @@
TavilyWebSearchTool,
normalize_legacy_web_search_config,
)
-from astrbot.core.utils.astrbot_path import get_astrbot_workspaces_path
+from astrbot.core.utils.astrbot_path import (
+ get_astrbot_system_tmp_path,
+ get_astrbot_workspaces_path,
+)
from astrbot.core.utils.file_extract import extract_file_moonshotai
from astrbot.core.utils.llm_metadata import LLM_METADATAS
from astrbot.core.utils.media_utils import (
@@ -1471,6 +1474,14 @@ async def build_main_agent(
fallback_providers=_get_fallback_chat_providers(
provider, plugin_context, config.provider_settings
),
+ tool_result_overflow_dir=(
+ get_astrbot_system_tmp_path()
+ if req.func_tool and req.func_tool.get_tool("astrbot_file_read_tool")
+ else None
+ ),
+ read_tool=(
+ req.func_tool.get_tool("astrbot_file_read_tool") if req.func_tool else None
+ ),
)
if apply_reset:
diff --git a/astrbot/core/computer/file_read_utils.py b/astrbot/core/computer/file_read_utils.py
index 52a83ceb85..0f4d0811cf 100644
--- a/astrbot/core/computer/file_read_utils.py
+++ b/astrbot/core/computer/file_read_utils.py
@@ -182,8 +182,22 @@ def detect_text_encoding(sample: bytes) -> str | None:
for encoding in _TEXT_ENCODINGS:
try:
decoded = sample.decode(encoding)
- except UnicodeDecodeError:
- continue
+ 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
diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py
index 058cf61e54..6192e23afd 100644
--- a/astrbot/core/star/context.py
+++ b/astrbot/core/star/context.py
@@ -36,6 +36,7 @@
PlatformAdapterType,
)
from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
+from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path
from ..exceptions import ProviderNotFoundError
from .filter.command import CommandFilter
@@ -232,6 +233,13 @@ async def tool_loop_agent(
for k, v in kwargs.items()
if k not in ["stream", "agent_hooks", "agent_context"]
}
+ if request.func_tool and request.func_tool.get_tool("astrbot_file_read_tool"):
+ other_kwargs.setdefault(
+ "tool_result_overflow_dir", get_astrbot_system_tmp_path()
+ )
+ other_kwargs.setdefault(
+ "read_tool", request.func_tool.get_tool("astrbot_file_read_tool")
+ )
await agent_runner.reset(
provider=prov,
diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py
index d18701f7b4..ba05a4fd15 100644
--- a/astrbot/core/tools/computer_tools/fs.py
+++ b/astrbot/core/tools/computer_tools/fs.py
@@ -46,6 +46,7 @@
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,
)
@@ -71,7 +72,7 @@ def _restricted_env_path_labels(umo: str) -> list[str]:
return [
"data/skills",
f"data/workspaces/{normalized_umo}",
- "/tmp/.astrbot",
+ get_astrbot_system_tmp_path(),
]
@@ -91,7 +92,7 @@ def _read_allowed_roots(umo: str) -> tuple[Path, ...]:
return (
Path(get_astrbot_skills_path()).resolve(strict=False),
_workspace_root(umo),
- Path("/tmp/.astrbot").resolve(strict=False),
+ Path(get_astrbot_system_tmp_path()).resolve(strict=False),
)
diff --git a/astrbot/core/utils/astrbot_path.py b/astrbot/core/utils/astrbot_path.py
index f7aea403f3..c7771c1a64 100644
--- a/astrbot/core/utils/astrbot_path.py
+++ b/astrbot/core/utils/astrbot_path.py
@@ -1,33 +1,33 @@
-"""Astrbot统一路径获取
-
-项目路径:固定为源码所在路径
-根目录路径:默认为当前工作目录,可通过环境变量 ASTRBOT_ROOT 指定
-数据目录路径:固定为根目录下的 data 目录
-配置文件路径:固定为数据目录下的 config 目录
-插件目录路径:固定为数据目录下的 plugins 目录
-插件数据目录路径:固定为数据目录下的 plugin_data 目录
-T2I 模板目录路径:固定为数据目录下的 t2i_templates 目录
-WebChat 数据目录路径:固定为数据目录下的 webchat 目录
-临时文件目录路径:固定为数据目录下的 temp 目录
-Skills 目录路径:固定为数据目录下的 skills 目录
-Workspaces 目录路径:固定为数据目录下的 workspaces 目录
-第三方依赖目录路径:固定为数据目录下的 site-packages 目录
+"""Centralized AstrBot path helpers.
+
+Project path:
+- Fixed to the source tree location.
+
+Root path:
+- Defaults to the current working directory.
+- Can be overridden with the ``ASTRBOT_ROOT`` environment variable.
+
+Data subdirectories:
+- Most runtime data lives under ``/data``.
+- A few tool-runtime files intentionally live under the system temporary
+ directory as ``.astrbot``.
"""
import os
+import tempfile
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
def get_astrbot_path() -> str:
- """获取Astrbot项目路径"""
+ """Return the AstrBot project source path."""
return os.path.realpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../../"),
)
def get_astrbot_root() -> str:
- """获取Astrbot根目录路径"""
+ """Return the AstrBot root directory."""
if path := os.environ.get("ASTRBOT_ROOT"):
return os.path.realpath(path)
if is_packaged_desktop_runtime():
@@ -36,60 +36,65 @@ def get_astrbot_root() -> str:
def get_astrbot_data_path() -> str:
- """获取Astrbot数据目录路径"""
+ """Return the AstrBot data directory path."""
return os.path.realpath(os.path.join(get_astrbot_root(), "data"))
def get_astrbot_config_path() -> str:
- """获取Astrbot配置文件路径"""
+ """Return the AstrBot config directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "config"))
def get_astrbot_plugin_path() -> str:
- """获取Astrbot插件目录路径"""
+ """Return the AstrBot plugin directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "plugins"))
def get_astrbot_plugin_data_path() -> str:
- """获取Astrbot插件数据目录路径"""
+ """Return the AstrBot plugin data directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "plugin_data"))
def get_astrbot_t2i_templates_path() -> str:
- """获取Astrbot T2I 模板目录路径"""
+ """Return the AstrBot T2I templates directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "t2i_templates"))
def get_astrbot_webchat_path() -> str:
- """获取Astrbot WebChat 数据目录路径"""
+ """Return the AstrBot WebChat data directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "webchat"))
def get_astrbot_temp_path() -> str:
- """获取Astrbot临时文件目录路径"""
+ """Return the AstrBot temporary data directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "temp"))
def get_astrbot_skills_path() -> str:
- """获取Astrbot Skills 目录路径"""
+ """Return the AstrBot skills directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "skills"))
def get_astrbot_workspaces_path() -> str:
- """获取Astrbot Workspaces 目录路径"""
+ """Return the AstrBot workspaces directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "workspaces"))
+def get_astrbot_system_tmp_path() -> str:
+ """Return the shared system temporary directory used by local tools."""
+ return os.path.realpath(os.path.join(tempfile.gettempdir(), ".astrbot"))
+
+
def get_astrbot_site_packages_path() -> str:
- """获取Astrbot第三方依赖目录路径"""
+ """Return the AstrBot third-party site-packages directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "site-packages"))
def get_astrbot_knowledge_base_path() -> str:
- """获取Astrbot知识库根目录路径"""
+ """Return the AstrBot knowledge base root path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "knowledge_base"))
def get_astrbot_backups_path() -> str:
- """获取Astrbot备份目录路径"""
+ """Return the AstrBot backups directory path."""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "backups"))
diff --git a/tests/test_computer_fs_tools.py b/tests/test_computer_fs_tools.py
index aaaed11a91..7fe33fe21d 100644
--- a/tests/test_computer_fs_tools.py
+++ b/tests/test_computer_fs_tools.py
@@ -92,6 +92,12 @@ def _make_large_text() -> str:
return "".join(f"line-{index:05d}-{'x' * 48}\n" for index in range(6000))
+def test_detect_text_encoding_allows_utf8_probe_cut_mid_character():
+ sample = '{"results": ["中文内容"]}'.encode()[:-1]
+
+ assert file_read_utils.detect_text_encoding(sample) in {"utf-8", "utf-8-sig"}
+
+
@pytest.mark.asyncio
async def test_file_read_tool_rejects_large_full_text_read_before_local_stream_read(
monkeypatch: pytest.MonkeyPatch,
@@ -249,7 +255,7 @@ async def _fake_parse_pdf(_file_bytes: bytes, _file_name: str) -> str:
assert len(converted_files) == 1
assert converted_files[0].read_text(encoding="utf-8") == long_text
assert str(converted_files[0]) in result
- assert "narrow `offset`/`limit` window" in result
+ assert "Read or grep that file with a narrow window." in result
@pytest.mark.asyncio
diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py
index be6833e87d..00caed67dd 100644
--- a/tests/test_tool_loop_agent_runner.py
+++ b/tests/test_tool_loop_agent_runner.py
@@ -1,6 +1,7 @@
import asyncio
import os
import sys
+from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock
@@ -97,6 +98,26 @@ async def generator():
return generator()
+class LargeTextToolExecutor:
+ """模拟返回超长文本的工具执行器"""
+
+ def __init__(self, text: str):
+ self.text = text
+
+ @classmethod
+ def from_text(cls, text: str) -> "LargeTextToolExecutor":
+ return cls(text)
+
+ def execute(self, tool, run_context, **tool_args):
+ async def generator():
+ from mcp.types import CallToolResult, TextContent
+
+ result = CallToolResult(content=[TextContent(type="text", text=self.text)])
+ yield result
+
+ return generator()
+
+
class MockMixedContentToolExecutor:
"""模拟返回图片 + 文本的工具执行器"""
@@ -193,6 +214,32 @@ async def text_chat(self, **kwargs) -> LLMResponse:
)
+class SingleToolThenFinalProvider(MockProvider):
+ def __init__(self, tool_name: str, tool_args: dict[str, str] | None = None):
+ super().__init__()
+ self.tool_name = tool_name
+ self.tool_args = tool_args or {}
+
+ async def text_chat(self, **kwargs) -> LLMResponse:
+ self.call_count += 1
+ func_tool = kwargs.get("func_tool")
+ if func_tool is None or self.call_count > 1:
+ return LLMResponse(
+ role="assistant",
+ completion_text="最终回复",
+ usage=TokenUsage(input_other=10, output=5),
+ )
+
+ return LLMResponse(
+ role="assistant",
+ completion_text="",
+ tools_call_name=[self.tool_name],
+ tools_call_args=[self.tool_args],
+ tools_call_ids=["call_large_result"],
+ usage=TokenUsage(input_other=10, output=5),
+ )
+
+
class SequentialToolProvider(MockProvider):
def __init__(self, tool_sequence: list[str]):
super().__init__()
@@ -334,6 +381,10 @@ def runner():
return ToolLoopAgentRunner()
+def _make_large_tool_result_text() -> str:
+ return "x" * 100000
+
+
@pytest.mark.asyncio
async def test_max_step_limit_functionality(
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
@@ -1124,18 +1175,116 @@ async def test_follow_up_accepted_when_active_and_not_stopping(
streaming=False,
)
- # Runner is active (not done) and stop is not requested
- assert not runner.done()
- assert runner._is_stop_requested() is False
- ticket = runner.follow_up(message_text="valid follow-up message")
+@pytest.mark.asyncio
+async def test_large_tool_result_is_spilled_to_file_and_replaced_with_read_notice(
+ tmp_path,
+):
+ tool = FunctionTool(
+ name="test_tool",
+ description="测试工具",
+ parameters={"type": "object", "properties": {"query": {"type": "string"}}},
+ handler=AsyncMock(),
+ )
+ read_tool = FunctionTool(
+ name="astrbot_file_read_tool",
+ description="read file",
+ parameters={"type": "object", "properties": {"path": {"type": "string"}}},
+ handler=AsyncMock(),
+ )
+ tool_set = ToolSet(tools=[tool, read_tool])
+ provider = SingleToolThenFinalProvider(tool.name, {"query": "large"})
+ request = ProviderRequest(prompt="run tool", func_tool=tool_set, contexts=[])
+ runner = ToolLoopAgentRunner()
+
+ await runner.reset(
+ provider=provider,
+ request=request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=cast(
+ Any,
+ LargeTextToolExecutor.from_text(_make_large_tool_result_text()),
+ ),
+ agent_hooks=MockHooks(),
+ streaming=False,
+ tool_result_overflow_dir=str(tmp_path),
+ read_tool=read_tool,
+ )
+
+ responses = []
+ async for response in runner.step_until_done(3):
+ responses.append(response)
+
+ tool_messages = [m for m in runner.run_context.messages if m.role == "tool"]
+ assert len(tool_messages) == 1
+ tool_message_content = str(tool_messages[0].content)
+ assert "xxxxxxxxxx" in tool_message_content
+ assert "Truncated tool output preview shown above." in tool_message_content
+ assert "The tool output was too large to include directly" in tool_message_content
+ assert "`astrbot_file_read_tool`" in tool_message_content
+ assert "Use `astrbot_file_read_tool` to inspect it." in tool_message_content
+
+ overflow_files = list(Path(tmp_path).glob("call_large_result_*.txt"))
+ assert len(overflow_files) == 1
+ assert (
+ overflow_files[0].read_text(encoding="utf-8") == _make_large_tool_result_text()
+ )
+ assert str(overflow_files[0]) in tool_message_content
+
+ llm_results = [resp for resp in responses if resp.type == "llm_result"]
+ assert llm_results
+
+
+@pytest.mark.asyncio
+async def test_large_tool_result_keeps_preview_when_spill_fails(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+):
+ tool = FunctionTool(
+ name="test_tool",
+ description="测试工具",
+ parameters={"type": "object", "properties": {"query": {"type": "string"}}},
+ handler=AsyncMock(),
+ )
+ read_tool = FunctionTool(
+ name="astrbot_file_read_tool",
+ description="read file",
+ parameters={"type": "object", "properties": {"path": {"type": "string"}}},
+ handler=AsyncMock(),
+ )
+ tool_set = ToolSet(tools=[tool, read_tool])
+ provider = SingleToolThenFinalProvider(tool.name, {"query": "large"})
+ request = ProviderRequest(prompt="run tool", func_tool=tool_set, contexts=[])
+ runner = ToolLoopAgentRunner()
+
+ async def _raise_spill_error(*, tool_call_id: str, content: str) -> str:
+ raise OSError("disk full")
+
+ monkeypatch.setattr(runner, "_write_tool_result_overflow_file", _raise_spill_error)
- assert ticket is not None, (
- "Follow-up should be accepted when runner is active and not stopping"
+ await runner.reset(
+ provider=provider,
+ request=request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=cast(
+ Any,
+ LargeTextToolExecutor.from_text(_make_large_tool_result_text()),
+ ),
+ agent_hooks=MockHooks(),
+ streaming=False,
+ tool_result_overflow_dir=str(tmp_path),
+ read_tool=read_tool,
)
- assert ticket.text == "valid follow-up message"
- assert ticket.consumed is False
- assert ticket in runner._pending_follow_ups
+
+ async for _ in runner.step_until_done(3):
+ pass
+
+ tool_messages = [m for m in runner.run_context.messages if m.role == "tool"]
+ assert len(tool_messages) == 1
+ tool_message_content = str(tool_messages[0].content)
+ assert "xxxxxxxxxx" in tool_message_content
+ assert "Tool output exceeded the inline result limit" in tool_message_content
+ assert "disk full" in tool_message_content
@pytest.mark.asyncio
From 157749504a665e939b273b0b11030a8a1173c550 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Fri, 10 Apr 2026 18:18:07 +0800
Subject: [PATCH 17/20] fix: test
---
astrbot/core/agent/runners/tool_loop_agent_runner.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py
index 5b952d545d..6e3ba40a98 100644
--- a/astrbot/core/agent/runners/tool_loop_agent_runner.py
+++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py
@@ -158,7 +158,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
TOOL_RESULT_OVERFLOW_NOTICE_TEMPLATE = (
"Truncated tool output preview shown above. "
"The tool output was too large to include directly and was written to "
- "`{overflow_path}`. Use {read_tool_hint} with a narrower window to inspect it."
+ "`{overflow_path}`. Use {read_tool_hint} to inspect it. "
+ "Use a narrower window when reading large files."
)
def _get_persona_custom_error_message(self) -> str | None:
From 31846cbba3b5faa6905d40b72978d2149dda9210 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Fri, 10 Apr 2026 19:15:07 +0800
Subject: [PATCH 18/20] feat: enhance onboarding steps to include computer
access configuration and related help information
---
.../i18n/locales/en-US/features/welcome.json | 23 ++-
.../i18n/locales/ru-RU/features/welcome.json | 25 ++-
.../i18n/locales/zh-CN/features/welcome.json | 23 ++-
dashboard/src/views/WelcomePage.vue | 176 ++++++++++++++++--
4 files changed, 216 insertions(+), 31 deletions(-)
diff --git a/dashboard/src/i18n/locales/en-US/features/welcome.json b/dashboard/src/i18n/locales/en-US/features/welcome.json
index 670d0a66de..7c12327749 100644
--- a/dashboard/src/i18n/locales/en-US/features/welcome.json
+++ b/dashboard/src/i18n/locales/en-US/features/welcome.json
@@ -12,11 +12,21 @@
"onboard": {
"title": "Quick Onboarding",
"subtitle": "Complete initialization directly on the welcome page.",
- "step1Title": "Configure Platform Bot",
- "step1Desc": "Connect AstrBot to IM platforms like QQ, Lark, Slack, Telegram, etc.",
- "step2Title": "Configure AI Model",
- "step2Desc": "Configure AI models for AstrBot.",
+ "step1Title": "Configure AI Model",
+ "step1Desc": "Configure AI models for AstrBot.",
+ "step2Title": "Configure Platform Bot",
+ "step2Desc": "Connect AstrBot to IM platforms like QQ, Lark, Slack, Telegram, etc.",
+ "step3Title": "Allow Agent to Use the Computer",
+ "step3Desc": "Set whether the Agent can access and use this computer.",
+ "step3HelpTitle": "Access Details",
+ "step3HelpItem1": "The Agent can access and use the workspace directory, which is isolated for each user, as well as Skills to handle more complex tasks.",
+ "step3HelpItem2": "For AstrBot administrators, the Agent is additionally allowed to execute shell commands, run Python code, and access all local directories on this machine.",
+ "step3HelpClose": "Close",
+ "step3SelectLabel": "Computer Access",
+ "step3Allow": "Allow",
+ "step3Deny": "Disallow",
"configure": "Configure",
+ "save": "Save",
"skip": "Skip",
"pending": "Pending",
"completed": "Completed",
@@ -24,7 +34,10 @@
"platformLoadFailed": "Failed to load platform configuration",
"providerLoadFailed": "Failed to load provider configuration",
"providerUpdateFailed": "Failed to update default chat provider in config file \"default\"",
- "providerDefaultUpdated": "Default chat provider in config file \"default\" has been set to {id}"
+ "providerDefaultUpdated": "Default chat provider in config file \"default\" has been set to {id}",
+ "computerAccessUpdateFailed": "Failed to update Agent computer access",
+ "computerAccessAllowed": "Agent is now allowed to access and use the computer",
+ "computerAccessDenied": "Agent is no longer allowed to access and use the computer"
},
"resources": {
"title": "Resources",
diff --git a/dashboard/src/i18n/locales/ru-RU/features/welcome.json b/dashboard/src/i18n/locales/ru-RU/features/welcome.json
index ea7d3043a1..8936518e95 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/welcome.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/welcome.json
@@ -12,11 +12,21 @@
"onboard": {
"title": "Быстрый старт",
"subtitle": "Вы можете выполнить первичную настройку прямо здесь.",
- "step1Title": "Настройка платформ",
- "step1Desc": "Подключите AstrBot к QQ, Lark, WeChat, Telegram и другим мессенджерам.",
- "step2Title": "Настройка AI моделей",
- "step2Desc": "Выберите и настройте AI провайдеров для AstrBot.",
+ "step1Title": "Настройка AI моделей",
+ "step1Desc": "Выберите и настройте AI провайдеров для AstrBot.",
+ "step2Title": "Настройка платформ",
+ "step2Desc": "Подключите AstrBot к QQ, Lark, WeChat, Telegram и другим мессенджерам.",
+ "step3Title": "Разрешить Agent использовать компьютер",
+ "step3Desc": "Укажите, может ли Agent получать доступ к этому компьютеру и использовать его.",
+ "step3HelpTitle": "Сведения о доступе",
+ "step3HelpItem1": "Agent сможет получать доступ к каталогу рабочего пространства и использовать его, при этом рабочие каталоги разных пользователей изолированы друг от друга, а также использовать Skills для выполнения более сложных задач.",
+ "step3HelpItem2": "Для администраторов AstrBot Agent дополнительно сможет выполнять shell-команды, запускать Python-код и получать доступ ко всем локальным каталогам на этом компьютере.",
+ "step3HelpClose": "Закрыть",
+ "step3SelectLabel": "Доступ к компьютеру",
+ "step3Allow": "Разрешить",
+ "step3Deny": "Запретить",
"configure": "Настроить",
+ "save": "Сохранить",
"skip": "Пропустить",
"pending": "Ожидает",
"completed": "Готово",
@@ -24,7 +34,10 @@
"platformLoadFailed": "Ошибка загрузки конфигурации платформ",
"providerLoadFailed": "Ошибка загрузки конфигурации провайдеров",
"providerUpdateFailed": "Ошибка обновления провайдера по умолчанию в файле default",
- "providerDefaultUpdated": "Провайдер {id} установлен по умолчанию в файле default"
+ "providerDefaultUpdated": "Провайдер {id} установлен по умолчанию в файле default",
+ "computerAccessUpdateFailed": "Не удалось обновить доступ Agent к компьютеру",
+ "computerAccessAllowed": "Agent теперь может получать доступ к компьютеру и использовать его",
+ "computerAccessDenied": "Agent больше не может получать доступ к компьютеру и использовать его"
},
"resources": {
"title": "Ресурсы",
@@ -34,4 +47,4 @@
"afdianTitle": "Afdian",
"afdianDesc": "Поддержите команду AstrBot через Afdian."
}
-}
\ No newline at end of file
+}
diff --git a/dashboard/src/i18n/locales/zh-CN/features/welcome.json b/dashboard/src/i18n/locales/zh-CN/features/welcome.json
index 1eb23d7ca4..ff968c2b9b 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/welcome.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/welcome.json
@@ -12,11 +12,21 @@
"onboard": {
"title": "快速引导",
"subtitle": "欢迎页可直接完成初始化。",
- "step1Title": "配置平台机器人",
- "step1Desc": "将 AstrBot 连接到 QQ、飞书、企业微信、Telegram 等 IM 平台。",
- "step2Title": "配置 AI 模型",
- "step2Desc": "为 AstrBot 配置 AI 模型。",
+ "step1Title": "配置 AI 模型",
+ "step1Desc": "为 AstrBot 配置 AI 模型。",
+ "step2Title": "配置平台机器人",
+ "step2Desc": "将 AstrBot 连接到 QQ、飞书、企业微信、Telegram 等 IM 平台。",
+ "step3Title": "允许 Agent 使用电脑",
+ "step3Desc": "设置 Agent 是否可以访问和使用当前电脑。",
+ "step3HelpTitle": "允许 Agent 使用电脑",
+ "step3HelpItem1": "Agent 将可以访问和使用工作区目录(每个用户的工作区目录相互独立)以及 Skills 来执行更多复杂任务。",
+ "step3HelpItem2": "对于 AstrBot 管理员,会额外允许 Agent 执行 Shell 命令、Python 代码,以及访问本机的所有目录。",
+ "step3HelpClose": "关闭",
+ "step3SelectLabel": "电脑访问权限",
+ "step3Allow": "允许",
+ "step3Deny": "不允许",
"configure": "去配置",
+ "save": "保存",
"skip": "跳过",
"pending": "待处理",
"completed": "已完成",
@@ -24,7 +34,10 @@
"platformLoadFailed": "加载平台配置失败",
"providerLoadFailed": "加载提供商配置失败",
"providerUpdateFailed": "更新 default 配置文件默认对话提供商失败",
- "providerDefaultUpdated": "已将 default 配置文件的默认对话提供商设置为 {id}"
+ "providerDefaultUpdated": "已将 default 配置文件的默认对话提供商设置为 {id}",
+ "computerAccessUpdateFailed": "更新 Agent 电脑访问权限失败",
+ "computerAccessAllowed": "已允许 Agent 访问和使用电脑",
+ "computerAccessDenied": "已禁止 Agent 访问和使用电脑"
},
"resources": {
"title": "相关资源",
diff --git a/dashboard/src/views/WelcomePage.vue b/dashboard/src/views/WelcomePage.vue
index 5cabdc66a8..a4872c3b09 100644
--- a/dashboard/src/views/WelcomePage.vue
+++ b/dashboard/src/views/WelcomePage.vue
@@ -20,17 +20,16 @@
-
+
{{ tm('onboard.step1Title') }}
{{ tm('onboard.step1Desc') }}
-
+
{{ tm('onboard.configure') }}
-
{{ tm('onboard.completed') }}
@@ -38,25 +37,58 @@
-
+
-
{{ tm('onboard.step2Title')
- }}
-
+
{{ tm('onboard.step2Title') }}
{{ tm('onboard.step2Desc') }}
-
+
{{ tm('onboard.configure') }}
-
{{ tm('onboard.completed') }}
+
+
+
+
+
{{ tm('onboard.step3Title') }}
+
+ ?
+
+
+
{{ tm('onboard.step3Desc') }}
+
+
+
+
+
@@ -136,6 +168,25 @@
+
+
+
+ {{ tm('onboard.step3HelpTitle') }}
+
+
+
+ - {{ tm('onboard.step3HelpItem1') }}
+ - {{ tm('onboard.step3HelpItem2') }}
+
+
+
+
+
+ {{ tm('onboard.step3HelpClose') }}
+
+
+
+
@@ -151,6 +202,7 @@ import 'markstream-vue/index.css';
import 'highlight.js/styles/github.css';
type StepState = 'pending' | 'completed' | 'skipped';
+type ComputerAccessRuntime = 'local' | 'none';
const { tm } = useModuleI18n('features/welcome');
const { locale } = useI18n();
@@ -158,6 +210,7 @@ const { success: showSuccess, error: showError } = useToast();
const showAddPlatformDialog = ref(false);
const showProviderDialog = ref(false);
+const showComputerAccessHelpDialog = ref(false);
const loadingPlatformDialog = ref(false);
const platformMetadata = ref