Compare commits

...

37 Commits

Author SHA1 Message Date
Soulter 71d4357ca7 chore: remove skills prompt 2026-01-15 16:08:58 +08:00
Soulter 97081bf543 feat: add sandbox mode prompt for enhanced user guidance in executing commands 2026-01-15 16:05:56 +08:00
Soulter 9c9239073e bugfixes 2026-01-15 14:29:46 +08:00
Soulter c78ac6acd7 feat: enhance iPython tool rendering with Shiki syntax highlighting 2026-01-15 14:01:43 +08:00
Soulter ac427af3c8 fix: ensure message stream order (#4487) 2026-01-15 13:11:49 +08:00
Soulter b7160c9c58 Merge remote-tracking branch 'origin/master' into feat/astrbot-agent-mode 2026-01-15 12:48:58 +08:00
Soulter 86715813ad project list 2026-01-15 12:46:22 +08:00
Soulter a363a2ddcd refactor: remove unused file tools and update PythonTool output handling 2026-01-15 01:50:48 +08:00
Soulter f2af8e58e2 fix: update description for command parameter in ExecuteShellTool 2026-01-15 00:58:34 +08:00
Soulter 937f0b7f32 fix: handle empty output case in PythonTool execution 2026-01-15 00:48:50 +08:00
Li-shi-ling c52ab1346b docs: standardize Context class documentation formatting (#4436)
* docs: standardize Context class documentation formatting

- Unified all method docstrings to standard format
- Fixed mixed language and formatting issues
- Added complete parameter and return descriptions
- Enhanced developer experience for plugin creators
- Fixes #4429

* docs: fix Context class documentation issues per review

- Restored Sphinx directives for versionadded notes
- Fixed MessageSesion typo to MessageSession throughout file
- Added clarification for kwargs propagation in tool_loop_agent
- Unified deprecation marker format
- Fixes #4429

* Convert developer API comments to English

* chore: revise comments

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-01-14 23:56:18 +08:00
Soulter c8fca4e6a0 fix: title saving logic and update project sessions on changes 2026-01-14 23:56:18 +08:00
Soulter 45397e941d feat: chatui project (#4477)
* feat: chatui-project

* fix: remove console log from getProjects function
2026-01-14 23:56:18 +08:00
時壹 ce0a024757 fix: sending OpenAI-style image_url causes Anthropic 400 invalid tag error (#4444) 2026-01-14 23:56:18 +08:00
Soulter 792e348076 feat: add file download functionality and update shipyard SDK version 2026-01-13 21:28:56 +08:00
Soulter 068094708e feat: add shipyard session configuration options and update related tools 2026-01-13 20:32:31 +08:00
Soulter 661bcfd890 feat: add availability check for sandbox in Shipyard and base booters 2026-01-13 20:02:18 +08:00
Soulter 0a58eaecdd fix 2026-01-13 19:45:14 +08:00
Soulter d564926e6b Merge remote-tracking branch 'origin/master' into feat/astrbot-agent-mode 2026-01-13 19:40:09 +08:00
Soulter 7d1709667e docs: refine EULA 2026-01-13 19:39:13 +08:00
Soulter ebdecf8dce feat: implement localStorage persistence for showReservedPlugins state 2026-01-13 19:39:13 +08:00
Soulter 3698b771dd chore: makes world better 2026-01-13 19:39:13 +08:00
Soulter 12b4ee0a2b remove 2026-01-13 19:36:36 +08:00
Soulter 9de0fe304c uv lock 2026-01-13 19:35:58 +08:00
Soulter e5cac2684f beta 2026-01-13 19:35:12 +08:00
Soulter 1646547cb4 fix 2026-01-13 19:34:07 +08:00
Soulter dca88d8ab8 feat: sandbox 2026-01-13 19:29:22 +08:00
Soulter 2e2da4b4ce feat: implement singleton pattern for ShipyardSandboxClient and add FileUploadTool for file uploads 2026-01-13 18:26:39 +08:00
Soulter 6df966e9a2 fix: remove 'boxlite' option from booter and handle error in PythonTool execution 2026-01-13 16:38:04 +08:00
Soulter a89e7b3f55 update 2026-01-13 01:48:45 +08:00
Soulter ea7c387fcb fix 2026-01-12 22:22:35 +08:00
Soulter 350c18b741 Merge remote-tracking branch 'origin/master' into feat/astrbot-agent-mode 2026-01-12 22:19:26 +08:00
Soulter fdbed75ce4 Merge remote-tracking branch 'origin/master' into feat/astrbot-agent-mode 2025-10-29 15:56:54 +08:00
Soulter 9fec29c1a3 feat: file upload 2025-10-04 23:42:59 +08:00
Soulter 972b5ffb86 fix: update tool call logging to include tool call IDs and enhance sandbox ship creation parameters 2025-10-03 00:46:02 +08:00
Soulter 33e67bf925 Merge remote-tracking branch 'origin/master' into feat/astrbot-agent-mode 2025-10-03 00:45:30 +08:00
Soulter 185501d1b5 stage 2025-10-02 14:06:23 +08:00
28 changed files with 1159 additions and 604 deletions
@@ -1,536 +0,0 @@
import asyncio
import json
import os
import re
import shutil
import time
import uuid
from collections import defaultdict
import aiodocker
import aiohttp
from astrbot.api import llm_tool, logger, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
from astrbot.api.message_components import File, Image
from astrbot.api.provider import ProviderRequest
from astrbot.core.message.components import BaseMessageComponent
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.io import download_file, download_image_by_url
PROMPT = """
## Task
You need to generate python codes to solve user's problem: {prompt}
{extra_input}
## Limit
1. Available libraries:
- standard libs
- `Pillow`
- `requests`
- `numpy`
- `matplotlib`
- `scipy`
- `scikit-learn`
- `beautifulsoup4`
- `pandas`
- `opencv-python`
- `python-docx`
- `python-pptx`
- `pymupdf` (Do not use fpdf, reportlab, etc.)
- `mplfonts`
You can only use these libraries and the libraries that they depend on.
2. Do not generate malicious code.
3. Use given `shared.api` package to output the result.
It has 3 functions: `send_text(text: str)`, `send_image(image_path: str)`, `send_file(file_path: str)`.
For Image and file, you must save it to `output` folder.
4. You must only output the code, do not output the result of the code and any other information.
5. The output language is same as user's input language.
6. Please first provide relevant knowledge about user's problem appropriately.
## Example
1. User's problem: `please solve the fabonacci sequence problem.`
Output:
```python
from shared.api import send_text, send_image, send_file
def fabonacci(n):
if n <= 1:
return n
else:
return fabonacci(n-1) + fabonacci(n-2)
result = fabonacci(10)
send_text("The fabonacci sequence is a series of numbers in which each number is the sum of the two preceding ones, starting from 0 and 1.")
send_text("Let's calculate the fabonacci sequence of 10: " + result) # send_text is a function to send pure text to user
```
2. User's problem: `please draw a sin(x) function.`
Output:
```python
from shared.api import send_text, send_image, send_file
import numpy as np
import matplotlib.pyplot as plt
x = np.linspace(0, 2*np.pi, 100)
y = np.sin(x)
plt.plot(x, y)
plt.savefig("output/sin_x.png")
send_text("The sin(x) is a periodic function with a period of 2π, and the value range is [-1, 1]. The following is the image of sin(x).")
send_image("output/sin_x.png") # send_image is a function to send image to user
send_text("If you need more information, please let me know :)")
```
{extra_prompt}
"""
DEFAULT_CONFIG = {
"sandbox": {
"image": "soulter/astrbot-code-interpreter-sandbox",
"docker_mirror": "", # cjie.eu.org
},
"docker_host_astrbot_abs_path": "",
}
PATH = os.path.join(get_astrbot_data_path(), "config", "python_interpreter.json")
class Main(star.Star):
"""基于 Docker 沙箱的 Python 代码执行器"""
def __init__(self, context: star.Context) -> None:
self.context = context
self.curr_dir = os.path.dirname(os.path.abspath(__file__))
self.shared_path = os.path.join("data", "py_interpreter_shared")
if not os.path.exists(self.shared_path):
# 复制 api.py 到 shared 目录
os.makedirs(self.shared_path, exist_ok=True)
shared_api_file = os.path.join(self.curr_dir, "shared", "api.py")
shutil.copy(shared_api_file, self.shared_path)
self.workplace_path = os.path.join("data", "py_interpreter_workplace")
os.makedirs(self.workplace_path, exist_ok=True)
self.user_file_msg_buffer = defaultdict(list)
"""存放用户上传的文件和图片"""
self.user_waiting = {}
"""正在等待用户的文件或图片"""
# 加载配置
if not os.path.exists(PATH):
self.config = DEFAULT_CONFIG
self._save_config()
else:
with open(PATH) as f:
self.config = json.load(f)
async def initialize(self):
ok = await self.is_docker_available()
if not ok:
logger.info(
"Docker 不可用,代码解释器将无法使用,astrbot-python-interpreter 将自动禁用。",
)
# await self.context._star_manager.turn_off_plugin(
# "astrbot-python-interpreter"
# )
async def file_upload(self, file_path: str):
"""上传图像文件到 S3"""
ext = os.path.splitext(file_path)[1]
S3_URL = "https://s3.neko.soulter.top/astrbot-s3"
with open(file_path, "rb") as f:
file = f.read()
s3_file_url = f"{S3_URL}/{uuid.uuid4().hex}{ext}"
async with (
aiohttp.ClientSession(
headers={"Accept": "application/json"},
trust_env=True,
) as session,
session.put(s3_file_url, data=file) as resp,
):
if resp.status != 200:
raise Exception(f"Failed to upload image: {resp.status}")
return s3_file_url
async def is_docker_available(self) -> bool:
"""Check if docker is available"""
try:
async with aiodocker.Docker() as docker:
await docker.version()
return True
except BaseException as e:
logger.info(f"检查 Docker 可用性: {e}")
return False
async def get_image_name(self) -> str:
"""Get the image name"""
if self.config["sandbox"]["docker_mirror"]:
return f"{self.config['sandbox']['docker_mirror']}/{self.config['sandbox']['image']}"
return self.config["sandbox"]["image"]
def _save_config(self):
with open(PATH, "w") as f:
json.dump(self.config, f)
async def gen_magic_code(self) -> str:
return uuid.uuid4().hex[:8]
async def download_image(
self,
image_url: str,
workplace_path: str,
filename: str,
) -> str:
"""Download image from url to workplace_path"""
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(image_url) as resp:
if resp.status != 200:
return ""
image_path = os.path.join(workplace_path, f"{filename}.jpg")
with open(image_path, "wb") as f:
f.write(await resp.read())
return f"{filename}.jpg"
async def tidy_code(self, code: str) -> str:
"""Tidy the code"""
pattern = r"```(?:py|python)?\n(.*?)\n```"
match = re.search(pattern, code, re.DOTALL)
if match is None:
raise ValueError("The code is not in the code block.")
return match.group(1)
@filter.event_message_type(filter.EventMessageType.ALL)
async def on_message(self, event: AstrMessageEvent):
"""处理消息"""
uid = event.get_sender_id()
if uid not in self.user_waiting:
return
for comp in event.message_obj.message:
if isinstance(comp, File):
file_path = await comp.get_file()
if file_path.startswith("http"):
name = comp.name if comp.name else uuid.uuid4().hex[:8]
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, name)
await download_file(file_path, path)
else:
path = file_path
self.user_file_msg_buffer[event.get_session_id()].append(path)
logger.debug(f"User {uid} uploaded file: {path}")
yield event.plain_result(f"代码执行器: 文件已经上传: {path}")
if uid in self.user_waiting:
del self.user_waiting[uid]
elif isinstance(comp, Image):
image_url = comp.url if comp.url else comp.file
if image_url is None:
raise ValueError("Image URL is None")
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
elif image_url.startswith("file:///"):
image_path = image_url.replace("file:///", "")
else:
image_path = image_url
self.user_file_msg_buffer[event.get_session_id()].append(image_path)
logger.debug(f"User {uid} uploaded image: {image_path}")
yield event.plain_result(f"代码执行器: 图片已经上传: {image_path}")
if uid in self.user_waiting:
del self.user_waiting[uid]
@filter.on_llm_request()
async def on_llm_req(self, event: AstrMessageEvent, request: ProviderRequest):
if event.get_session_id() in self.user_file_msg_buffer:
files = self.user_file_msg_buffer[event.get_session_id()]
if not request.prompt:
request.prompt = ""
request.prompt += f"\nUser provided files: {files}"
@filter.command_group("pi")
def pi(self):
"""代码执行器配置"""
@pi.command("absdir")
async def pi_absdir(self, event: AstrMessageEvent, path: str = ""):
"""设置 Docker 宿主机绝对路径"""
if not path:
yield event.plain_result(
f"当前 Docker 宿主机绝对路径: {self.config.get('docker_host_astrbot_abs_path', '')}",
)
else:
self.config["docker_host_astrbot_abs_path"] = path
self._save_config()
yield event.plain_result(f"设置 Docker 宿主机绝对路径成功: {path}")
@pi.command("mirror")
async def pi_mirror(self, event: AstrMessageEvent, url: str = ""):
"""Docker 镜像地址"""
if not url:
yield event.plain_result(f"""当前 Docker 镜像地址: {self.config["sandbox"]["docker_mirror"]}
使用 `pi mirror <url>` 来设置 Docker 镜像地址。
您所设置的 Docker 镜像地址将会自动加在 Docker 镜像名前。如: `soulter/astrbot-code-interpreter-sandbox` -> `cjie.eu.org/soulter/astrbot-code-interpreter-sandbox`。
""")
else:
self.config["sandbox"]["docker_mirror"] = url
self._save_config()
yield event.plain_result("设置 Docker 镜像地址成功。")
@pi.command("repull")
async def pi_repull(self, event: AstrMessageEvent):
"""重新拉取沙箱镜像"""
async with aiodocker.Docker() as docker:
image_name = await self.get_image_name()
try:
await docker.images.get(image_name)
await docker.images.delete(image_name, force=True)
except aiodocker.exceptions.DockerError:
pass
await docker.images.pull(image_name)
yield event.plain_result("重新拉取沙箱镜像成功。")
@pi.command("file")
async def pi_file(self, event: AstrMessageEvent):
"""在规定秒数(60s)内上传一个文件"""
uid = event.get_sender_id()
self.user_waiting[uid] = time.time()
tip = "文件"
yield event.plain_result(f"代码执行器: 请在 60s 内上传一个{tip}")
await asyncio.sleep(60)
if uid in self.user_waiting:
yield event.plain_result(
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 未在规定时间内上传{tip}",
)
self.user_waiting.pop(uid)
@pi.command("clear", alias=["clean"])
async def pi_file_clean(self, event: AstrMessageEvent):
"""清理用户上传的文件"""
uid = event.get_sender_id()
if uid in self.user_waiting:
self.user_waiting.pop(uid)
yield event.plain_result(
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 已清理。",
)
else:
yield event.plain_result(
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 没有等待上传文件。",
)
@pi.command("list")
async def pi_file_list(self, event: AstrMessageEvent):
"""列出用户上传的文件"""
uid = event.get_sender_id()
if uid in self.user_file_msg_buffer:
files = self.user_file_msg_buffer[uid]
yield event.plain_result(
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 上传的文件: {files}",
)
else:
yield event.plain_result(
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 没有上传文件。",
)
@llm_tool("python_interpreter")
async def python_interpreter(self, event: AstrMessageEvent):
"""Use this tool only if user really want to solve a complex problem and the problem can be solved very well by Python code.
For example, user can use this tool to solve math problems, edit image, docx, pptx, pdf, etc.
"""
if not await self.is_docker_available():
yield event.plain_result("Docker 在当前机器不可用,无法沙箱化执行代码。")
plain_text = event.message_str
# 创建必要的工作目录和幻术码
magic_code = await self.gen_magic_code()
workplace_path = os.path.join(self.workplace_path, magic_code)
output_path = os.path.join(workplace_path, "output")
os.makedirs(workplace_path, exist_ok=True)
os.makedirs(output_path, exist_ok=True)
files = []
# 文件
for file_path in self.user_file_msg_buffer[event.get_session_id()]:
if not file_path:
continue
elif not os.path.exists(file_path):
logger.warning(f"文件 {file_path} 不存在,已忽略。")
continue
# cp
file_name = os.path.basename(file_path)
shutil.copy(file_path, os.path.join(workplace_path, file_name))
files.append(file_name)
logger.debug(f"user query: {plain_text}, files: {files}")
# 整理额外输入
extra_inputs = ""
if files:
extra_inputs += f"User provided files: {files}\n"
obs = ""
n = 5
async with aiodocker.Docker() as docker:
for i in range(n):
if i > 0:
logger.info(f"Try {i + 1}/{n}")
PROMPT_ = PROMPT.format(
prompt=plain_text,
extra_input=extra_inputs,
extra_prompt=obs,
)
provider = self.context.get_using_provider()
llm_response = await provider.text_chat(
prompt=PROMPT_,
session_id=f"{event.session_id}_{magic_code}_{i!s}",
)
logger.debug(
"code interpreter llm gened code:" + llm_response.completion_text,
)
# 整理代码并保存
code_clean = await self.tidy_code(llm_response.completion_text)
with open(os.path.join(workplace_path, "exec.py"), "w") as f:
f.write(code_clean)
# 检查有没有image
image_name = await self.get_image_name()
try:
await docker.images.get(image_name)
except aiodocker.exceptions.DockerError:
# 拉取镜像
logger.info(f"未找到沙箱镜像,正在尝试拉取 {image_name}...")
await docker.images.pull(image_name)
yield event.plain_result(
f"使用沙箱执行代码中,请稍等...(尝试次数: {i + 1}/{n})",
)
self.docker_host_astrbot_abs_path = self.config.get(
"docker_host_astrbot_abs_path",
"",
)
if self.docker_host_astrbot_abs_path:
host_shared = os.path.join(
self.docker_host_astrbot_abs_path,
self.shared_path,
)
host_output = os.path.join(
self.docker_host_astrbot_abs_path,
output_path,
)
host_workplace = os.path.join(
self.docker_host_astrbot_abs_path,
workplace_path,
)
else:
host_shared = os.path.abspath(self.shared_path)
host_output = os.path.abspath(output_path)
host_workplace = os.path.abspath(workplace_path)
logger.debug(
f"host_shared: {host_shared}, host_output: {host_output}, host_workplace: {host_workplace}",
)
container = await docker.containers.run(
{
"Image": image_name,
"Cmd": ["python", "exec.py"],
"Memory": 512 * 1024 * 1024,
"NanoCPUs": 1000000000,
"HostConfig": {
"Binds": [
f"{host_shared}:/astrbot_sandbox/shared:ro",
f"{host_output}:/astrbot_sandbox/output:rw",
f"{host_workplace}:/astrbot_sandbox:rw",
],
},
"Env": [f"MAGIC_CODE={magic_code}"],
"AutoRemove": True,
},
)
logger.debug(f"Container {container.id} created.")
logs = await self.run_container(container)
logger.debug(f"Container {container.id} finished.")
logger.debug(f"Container {container.id} logs: {logs}")
# 发送结果
pattern = r"\[ASTRBOT_(TEXT|IMAGE|FILE)_OUTPUT#\w+\]: (.*)"
ok = False
traceback = ""
for idx, log in enumerate(logs):
match = re.match(pattern, log)
if match:
ok = True
if match.group(1) == "TEXT":
yield event.plain_result(match.group(2))
elif match.group(1) == "IMAGE":
image_path = os.path.join(workplace_path, match.group(2))
logger.debug(f"Sending image: {image_path}")
yield event.image_result(image_path)
elif match.group(1) == "FILE":
file_path = os.path.join(workplace_path, match.group(2))
# logger.debug(f"Sending file: {file_path}")
# file_s3_url = await self.file_upload(file_path)
# logger.info(f"文件上传到 AstrBot 云节点: {file_s3_url}")
file_name = os.path.basename(file_path)
chain: list[BaseMessageComponent] = [
File(name=file_name, file=file_path)
]
yield event.set_result(MessageEventResult(chain=chain))
elif (
"Traceback (most recent call last)" in log or "[Error]: " in log
):
traceback = "\n".join(logs[idx:])
if not ok:
if traceback:
obs = f"## Observation \n When execute the code: ```python\n{code_clean}\n```\n\n Error occurred:\n\n{traceback}\n Need to improve/fix the code."
else:
logger.warning(
f"未从沙箱输出中捕获到合法的输出。沙箱输出日志: {logs}",
)
break
else:
# 成功了
self.user_file_msg_buffer.pop(event.get_session_id())
return
yield event.plain_result(
"经过多次尝试后,未从沙箱输出中捕获到合法的输出,请更换问法或者查看日志。",
)
@pi.command("cleanfile")
async def pi_cleanfile(self, event: AstrMessageEvent):
"""清理用户上传的文件"""
for file in self.user_file_msg_buffer[event.get_session_id()]:
try:
os.remove(file)
except BaseException as e:
logger.error(f"删除文件 {file} 失败: {e}")
self.user_file_msg_buffer.pop(event.get_session_id())
yield event.plain_result(f"用户 {event.get_session_id()} 上传的文件已清理。")
async def run_container(
self,
container: aiodocker.docker.DockerContainer,
timeout: int = 20,
) -> list[str]:
"""Run the container and get the output"""
try:
await container.wait(timeout=timeout)
logs = await container.log(stdout=True, stderr=True)
return logs
except asyncio.TimeoutError:
logger.warning(f"Container {container.id} timeout.")
await container.kill()
return [f"[Error]: Container has been killed due to timeout ({timeout}s)."]
finally:
await container.delete()
@@ -1,4 +0,0 @@
name: astrbot-python-interpreter
desc: Python 代码执行器
author: Soulter
version: 0.0.1
@@ -1 +0,0 @@
aiodocker
@@ -1,22 +0,0 @@
import os
def _get_magic_code():
"""防止注入攻击"""
return os.getenv("MAGIC_CODE")
def send_text(text: str):
print(f"[ASTRBOT_TEXT_OUTPUT#{_get_magic_code()}]: {text}")
def send_image(image_path: str):
if not os.path.exists(image_path):
raise Exception(f"Image file not found: {image_path}")
print(f"[ASTRBOT_IMAGE_OUTPUT#{_get_magic_code()}]: {image_path}")
def send_file(file_path: str):
if not os.path.exists(file_path):
raise Exception(f"File not found: {file_path}")
print(f"[ASTRBOT_FILE_OUTPUT#{_get_magic_code()}]: {file_path}")
+64
View File
@@ -113,6 +113,14 @@ DEFAULT_CONFIG = {
"provider": "moonshotai",
"moonshotai_api_key": "",
},
"sandbox": {
"enable": False,
"booter": "shipyard",
"shipyard_endpoint": "",
"shipyard_access_token": "",
"shipyard_ttl": 3600,
"shipyard_max_sessions": 10,
},
},
"provider_stt_settings": {
"enable": False,
@@ -2539,6 +2547,62 @@ CONFIG_METADATA_3 = {
# "provider_settings.enable": True,
# },
# },
"sandbox": {
"description": "Agent 沙箱环境",
"type": "object",
"items": {
"provider_settings.sandbox.enable": {
"description": "启用沙箱环境",
"type": "bool",
"hint": "启用后,Agent 可以使用沙箱环境中的工具和资源,如 Python 代码执行、Shell 等。",
},
"provider_settings.sandbox.booter": {
"description": "沙箱环境驱动器",
"type": "string",
"options": ["shipyard"],
"condition": {
"provider_settings.sandbox.enable": True,
},
},
"provider_settings.sandbox.shipyard_endpoint": {
"description": "Shipyard API Endpoint",
"type": "string",
"hint": "Shipyard 服务的 API 访问地址。",
"condition": {
"provider_settings.sandbox.enable": True,
"provider_settings.sandbox.booter": "shipyard",
},
"_special": "check_shipyard_connection",
},
"provider_settings.sandbox.shipyard_access_token": {
"description": "Shipyard Access Token",
"type": "string",
"hint": "用于访问 Shipyard 服务的访问令牌。",
"condition": {
"provider_settings.sandbox.enable": True,
"provider_settings.sandbox.booter": "shipyard",
},
},
"provider_settings.sandbox.shipyard_ttl": {
"description": "Shipyard Session TTL",
"type": "int",
"hint": "Shipyard 会话的生存时间(秒)。",
"condition": {
"provider_settings.sandbox.enable": True,
"provider_settings.sandbox.booter": "shipyard",
},
},
"provider_settings.sandbox.shipyard_max_sessions": {
"description": "Shipyard Max Sessions",
"type": "int",
"hint": "Shipyard 最大会话数量。",
"condition": {
"provider_settings.sandbox.enable": True,
"provider_settings.sandbox.booter": "shipyard",
},
},
},
},
"truncate_and_compress": {
"description": "上下文管理策略",
"type": "object",
@@ -2,10 +2,11 @@
import asyncio
import json
import os
from collections.abc import AsyncGenerator
from astrbot.core import logger
from astrbot.core.agent.message import Message
from astrbot.core.agent.message import Message, TextPart
from astrbot.core.agent.response import AgentStats
from astrbot.core.agent.tool import ToolSet
from astrbot.core.astr_agent_context import AstrAgentContext
@@ -35,8 +36,13 @@ from .....astr_agent_tool_exec import FunctionToolExecutor
from ....context import PipelineContext, call_event_hook
from ...stage import Stage
from ...utils import (
EXECUTE_SHELL_TOOL,
FILE_DOWNLOAD_TOOL,
FILE_UPLOAD_TOOL,
KNOWLEDGE_BASE_QUERY_TOOL,
LLM_SAFETY_MODE_SYSTEM_PROMPT,
PYTHON_TOOL,
SANDBOX_MODE_PROMPT,
decoded_blocked,
retrieve_knowledge_base,
)
@@ -94,6 +100,8 @@ class InternalAgentSubStage(Stage):
"safety_mode_strategy", "system_prompt"
)
self.sandbox_cfg = settings.get("sandbox", {})
self.conv_manager = ctx.plugin_manager.context.conversation_manager
def _select_provider(self, event: AstrMessageEvent):
@@ -458,6 +466,24 @@ class InternalAgentSubStage(Stage):
f"Unsupported llm_safety_mode strategy: {self.safety_mode_strategy}.",
)
def _apply_sandbox_tools(self, req: ProviderRequest, session_id: str) -> None:
"""Add sandbox tools to the provider request."""
if req.func_tool is None:
req.func_tool = ToolSet()
if self.sandbox_cfg.get("booter") == "shipyard":
ep = self.sandbox_cfg.get("shipyard_endpoint", "")
at = self.sandbox_cfg.get("shipyard_access_token", "")
if not ep or not at:
logger.error("Shipyard sandbox configuration is incomplete.")
return
os.environ["SHIPYARD_ENDPOINT"] = ep
os.environ["SHIPYARD_ACCESS_TOKEN"] = at
req.func_tool.add_tool(EXECUTE_SHELL_TOOL)
req.func_tool.add_tool(PYTHON_TOOL)
req.func_tool.add_tool(FILE_UPLOAD_TOOL)
req.func_tool.add_tool(FILE_DOWNLOAD_TOOL)
req.system_prompt += f"\n{SANDBOX_MODE_PROMPT}\n"
async def process(
self, event: AstrMessageEvent, provider_wake_prefix: str
) -> AsyncGenerator[None, None]:
@@ -536,6 +562,20 @@ class InternalAgentSubStage(Stage):
image_path = await comp.convert_to_file_path()
req.image_urls.append(image_path)
req.extra_user_content_parts.append(
TextPart(text=f"[Image Attachment: path {image_path}]")
)
elif isinstance(comp, File) and self.sandbox_cfg.get(
"enable", False
):
file_path = await comp.get_file()
file_name = comp.name or os.path.basename(file_path)
req.extra_user_content_parts.append(
TextPart(
text=f"[File Attachment: name {file_name}, path {file_path}]"
)
)
conversation = await self._get_session_conv(event)
req.conversation = conversation
req.contexts = json.loads(conversation.history)
@@ -586,6 +626,10 @@ class InternalAgentSubStage(Stage):
if self.llm_safety_mode:
self._apply_llm_safety_mode(req)
# apply sandbox tools
if self.sandbox_cfg.get("enable", False):
self._apply_sandbox_tools(req, req.session_id)
stream_to_general = (
self.unsupported_streaming_strategy == "turn_off"
and not event.platform_meta.support_streaming_message
@@ -7,6 +7,12 @@ from astrbot.api import logger, sp
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.sandbox.tools import (
ExecuteShellTool,
FileDownloadTool,
FileUploadTool,
PythonTool,
)
from astrbot.core.star.context import Context
LLM_SAFETY_MODE_SYSTEM_PROMPT = """You are running in Safe Mode.
@@ -21,6 +27,20 @@ Rules:
- Output same language as the user's input.
"""
SANDBOX_MODE_PROMPT = (
"You have access to a sandboxed environment and can execute shell commands and Python code securely."
# "Your have extended skills library, such as PDF processing, image generation, data analysis, etc. "
# "Before handling complex tasks, please retrieve and review the documentation in the in /app/skills/ directory. "
# "If the current task matches the description of a specific skill, prioritize following the workflow defined by that skill."
# "Use `ls /app/skills/` to list all available skills. "
# "Use `cat /app/skills/{skill_name}/SKILL.md` to read the documentation of a specific skill."
# "SKILL.md might be large, you can read the description first, which is located in the YAML frontmatter of the file."
# "Use shell commands such as grep, sed, awk to extract relevant information from the documentation as needed.\n"
"Note:\n"
"1. If you use shell, your command will always runs in the /home/<username>/workspace directory.\n"
"2. If you use IPython, you would better use absolute paths when dealing with files to avoid confusion.\n"
)
@dataclass
class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
@@ -138,6 +158,11 @@ async def retrieve_knowledge_base(
KNOWLEDGE_BASE_QUERY_TOOL = KnowledgeBaseQueryTool()
EXECUTE_SHELL_TOOL = ExecuteShellTool()
PYTHON_TOOL = PythonTool()
FILE_UPLOAD_TOOL = FileUploadTool()
FILE_DOWNLOAD_TOOL = FileDownloadTool()
# we prevent astrbot from connecting to known malicious hosts
# these hosts are base64 encoded
BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"}
@@ -93,7 +93,8 @@ class WebChatAdapter(Platform):
session: MessageSesion,
message_chain: MessageChain,
):
await WebChatMessageEvent._send(message_chain, session.session_id)
message_id = f"active_{str(uuid.uuid4())}"
await WebChatMessageEvent._send(message_id, message_chain, session.session_id)
await super().send_by_session(session, message_chain)
async def _get_message_history(
@@ -196,7 +197,7 @@ class WebChatAdapter(Platform):
abm.session_id = f"webchat!{username}!{cid}"
abm.message_id = str(uuid.uuid4())
abm.message_id = payload.get("message_id")
# 处理消息段列表
message_parts = payload.get("message", [])
@@ -21,7 +21,10 @@ class WebChatMessageEvent(AstrMessageEvent):
@staticmethod
async def _send(
message: MessageChain | None, session_id: str, streaming: bool = False
message_id: str,
message: MessageChain | None,
session_id: str,
streaming: bool = False,
) -> str | None:
cid = session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
@@ -31,6 +34,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"type": "end",
"data": "",
"streaming": False,
"message_id": message_id,
}, # end means this request is finished
)
return
@@ -45,6 +49,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"data": data,
"streaming": streaming,
"chain_type": message.type,
"message_id": message_id,
},
)
elif isinstance(comp, Json):
@@ -54,6 +59,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"data": json.dumps(comp.data, ensure_ascii=False),
"streaming": streaming,
"chain_type": message.type,
"message_id": message_id,
},
)
elif isinstance(comp, Image):
@@ -69,6 +75,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"type": "image",
"data": data,
"streaming": streaming,
"message_id": message_id,
},
)
elif isinstance(comp, Record):
@@ -84,6 +91,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"type": "record",
"data": data,
"streaming": streaming,
"message_id": message_id,
},
)
elif isinstance(comp, File):
@@ -94,12 +102,13 @@ class WebChatMessageEvent(AstrMessageEvent):
filename = f"{uuid.uuid4()!s}{ext}"
dest_path = os.path.join(imgs_dir, filename)
shutil.copy2(file_path, dest_path)
data = f"[FILE]{filename}|{original_name}"
data = f"[FILE]{filename}"
await web_chat_back_queue.put(
{
"type": "file",
"data": data,
"streaming": streaming,
"message_id": message_id,
},
)
else:
@@ -108,7 +117,8 @@ class WebChatMessageEvent(AstrMessageEvent):
return data
async def send(self, message: MessageChain | None):
await WebChatMessageEvent._send(message, session_id=self.session_id)
message_id = self.message_obj.message_id
await WebChatMessageEvent._send(message_id, message, session_id=self.session_id)
await super().send(MessageChain([]))
async def send_streaming(self, generator, use_fallback: bool = False):
@@ -116,6 +126,7 @@ class WebChatMessageEvent(AstrMessageEvent):
reasoning_content = ""
cid = self.session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
message_id = self.message_obj.message_id
async for chain in generator:
# if chain.type == "break" and final_data:
# # 分割符
@@ -130,7 +141,8 @@ class WebChatMessageEvent(AstrMessageEvent):
# continue
r = await WebChatMessageEvent._send(
chain,
message_id=message_id,
message=chain,
session_id=self.session_id,
streaming=True,
)
@@ -147,6 +159,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"data": final_data,
"reasoning": reasoning_content,
"streaming": True,
"message_id": message_id,
},
)
await super().send_streaming(generator, use_fallback)
+31
View File
@@ -0,0 +1,31 @@
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
class SandboxBooter:
@property
def fs(self) -> FileSystemComponent: ...
@property
def python(self) -> PythonComponent: ...
@property
def shell(self) -> ShellComponent: ...
async def boot(self, session_id: str) -> None: ...
async def shutdown(self) -> None: ...
async def upload_file(self, path: str, file_name: str) -> dict:
"""Upload file to sandbox.
Should return a dict with `success` (bool) and `file_path` (str) keys.
"""
...
async def download_file(self, remote_path: str, local_path: str):
"""Download file from sandbox."""
...
async def available(self) -> bool:
"""Check if the sandbox is available."""
...
+186
View File
@@ -0,0 +1,186 @@
import asyncio
import random
from typing import Any
import aiohttp
import boxlite
from shipyard.filesystem import FileSystemComponent as ShipyardFileSystemComponent
from shipyard.python import PythonComponent as ShipyardPythonComponent
from shipyard.shell import ShellComponent as ShipyardShellComponent
from astrbot.api import logger
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import SandboxBooter
class MockShipyardSandboxClient:
def __init__(self, sb_url: str) -> None:
self.sb_url = sb_url.rstrip("/")
async def _exec_operation(
self,
ship_id: str,
operation_type: str,
payload: dict[str, Any],
session_id: str,
) -> dict[str, Any]:
async with aiohttp.ClientSession() as session:
headers = {"X-SESSION-ID": session_id}
async with session.post(
f"{self.sb_url}/{operation_type}",
json=payload,
headers=headers,
) as response:
if response.status == 200:
return await response.json()
else:
error_text = await response.text()
raise Exception(
f"Failed to exec operation: {response.status} {error_text}"
)
async def upload_file(self, path: str, remote_path: str) -> dict:
"""Upload a file to the sandbox"""
url = f"http://{self.sb_url}/upload"
try:
# Read file content
with open(path, "rb") as f:
file_content = f.read()
# Create multipart form data
data = aiohttp.FormData()
data.add_field(
"file",
file_content,
filename=remote_path.split("/")[-1],
content_type="application/octet-stream",
)
data.add_field("file_path", remote_path)
timeout = aiohttp.ClientTimeout(total=120) # 2 minutes for file upload
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, data=data) as response:
if response.status == 200:
return {
"success": True,
"message": "File uploaded successfully",
"file_path": remote_path,
}
else:
error_text = await response.text()
return {
"success": False,
"error": f"Server returned {response.status}: {error_text}",
"message": "File upload failed",
}
except aiohttp.ClientError as e:
logger.error(f"Failed to upload file: {e}")
return {
"success": False,
"error": f"Connection error: {str(e)}",
"message": "File upload failed",
}
except asyncio.TimeoutError:
return {
"success": False,
"error": "File upload timeout",
"message": "File upload failed",
}
except FileNotFoundError:
logger.error(f"File not found: {path}")
return {
"success": False,
"error": f"File not found: {path}",
"message": "File upload failed",
}
except Exception as e:
logger.error(f"Unexpected error uploading file: {e}")
return {
"success": False,
"error": f"Internal error: {str(e)}",
"message": "File upload failed",
}
async def wait_healthy(self, ship_id: str, session_id: str) -> None:
"""Mock wait healthy"""
loop = 60
while loop > 0:
try:
logger.info(
f"Checking health for sandbox {ship_id} on {self.sb_url}..."
)
url = f"{self.sb_url}/health"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 200:
logger.info(f"Sandbox {ship_id} is healthy")
return
except Exception:
await asyncio.sleep(1)
loop -= 1
class BoxliteBooter(SandboxBooter):
async def boot(self, session_id: str) -> None:
logger.info(
f"Booting(Boxlite) for session: {session_id}, this may take a while..."
)
random_port = random.randint(20000, 30000)
self.box = boxlite.SimpleBox(
image="soulter/shipyard-ship",
memory_mib=512,
cpus=1,
ports=[
{
"host_port": random_port,
"guest_port": 8123,
}
],
)
await self.box.start()
logger.info(f"Boxlite booter started for session: {session_id}")
self.mocked = MockShipyardSandboxClient(
sb_url=f"http://127.0.0.1:{random_port}"
)
self._fs = ShipyardFileSystemComponent(
client=self.mocked, # type: ignore
ship_id=self.box.id,
session_id=session_id,
)
self._python = ShipyardPythonComponent(
client=self.mocked, # type: ignore
ship_id=self.box.id,
session_id=session_id,
)
self._shell = ShipyardShellComponent(
client=self.mocked, # type: ignore
ship_id=self.box.id,
session_id=session_id,
)
await self.mocked.wait_healthy(self.box.id, session_id)
async def shutdown(self) -> None:
logger.info(f"Shutting down Boxlite booter for ship: {self.box.id}")
self.box.shutdown()
logger.info(f"Boxlite booter for ship: {self.box.id} stopped")
@property
def fs(self) -> FileSystemComponent:
return self._fs
@property
def python(self) -> PythonComponent:
return self._python
@property
def shell(self) -> ShellComponent:
return self._shell
async def upload_file(self, path: str, file_name: str) -> dict:
"""Upload file to sandbox"""
return await self.mocked.upload_file(path, file_name)
+67
View File
@@ -0,0 +1,67 @@
from shipyard import ShipyardClient, Spec
from astrbot.api import logger
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import SandboxBooter
class ShipyardBooter(SandboxBooter):
def __init__(
self,
endpoint_url: str,
access_token: str,
ttl: int = 3600,
session_num: int = 10,
) -> None:
self._sandbox_client = ShipyardClient(
endpoint_url=endpoint_url, access_token=access_token
)
self._ttl = ttl
self._session_num = session_num
async def boot(self, session_id: str) -> None:
ship = await self._sandbox_client.create_ship(
ttl=self._ttl,
spec=Spec(cpus=1.0, memory="512m"),
max_session_num=self._session_num,
session_id=session_id,
)
logger.info(f"Got sandbox ship: {ship.id} for session: {session_id}")
self._ship = ship
async def shutdown(self) -> None:
pass
@property
def fs(self) -> FileSystemComponent:
return self._ship.fs
@property
def python(self) -> PythonComponent:
return self._ship.python
@property
def shell(self) -> ShellComponent:
return self._ship.shell
async def upload_file(self, path: str, file_name: str) -> dict:
"""Upload file to sandbox"""
return await self._ship.upload_file(path, file_name)
async def download_file(self, remote_path: str, local_path: str):
"""Download file from sandbox."""
return await self._ship.download_file(remote_path, local_path)
async def available(self) -> bool:
"""Check if the sandbox is available."""
try:
ship_id = self._ship.id
data = await self._sandbox_client.get_ship(ship_id)
if not data:
return False
health = bool(data.get("status", 0) == 1)
return health
except Exception as e:
logger.error(f"Error checking Shipyard sandbox availability: {e}")
return False
+5
View File
@@ -0,0 +1,5 @@
from .filesystem import FileSystemComponent
from .python import PythonComponent
from .shell import ShellComponent
__all__ = ["PythonComponent", "ShellComponent", "FileSystemComponent"]
+33
View File
@@ -0,0 +1,33 @@
"""
File system component
"""
from typing import Any, Protocol
class FileSystemComponent(Protocol):
async def create_file(
self, path: str, content: str = "", mode: int = 0o644
) -> dict[str, Any]:
"""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 write_file(
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
) -> dict[str, Any]:
"""Write content to file"""
...
async def delete_file(self, path: str) -> dict[str, Any]:
"""Delete file or directory"""
...
async def list_dir(
self, path: str = ".", show_hidden: bool = False
) -> dict[str, Any]:
"""List directory contents"""
...
+19
View File
@@ -0,0 +1,19 @@
"""
Python/IPython component
"""
from typing import Any, Protocol
class PythonComponent(Protocol):
"""Python/IPython operations component"""
async def exec(
self,
code: str,
kernel_id: str | None = None,
timeout: int = 30,
silent: bool = False,
) -> dict[str, Any]:
"""Execute Python code"""
...
+21
View File
@@ -0,0 +1,21 @@
"""
Shell component
"""
from typing import Any, Protocol
class ShellComponent(Protocol):
"""Shell operations component"""
async def exec(
self,
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 30,
shell: bool = True,
background: bool = False,
) -> dict[str, Any]:
"""Execute shell command"""
...
+52
View File
@@ -0,0 +1,52 @@
import uuid
from astrbot.api import logger
from astrbot.core.star.context import Context
from .booters.base import SandboxBooter
session_booter: dict[str, SandboxBooter] = {}
async def get_booter(
context: Context,
session_id: str,
) -> SandboxBooter:
config = context.get_config(umo=session_id)
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
booter_type = sandbox_cfg.get("booter", "shipyard")
if session_id in session_booter:
booter = session_booter[session_id]
if not await booter.available():
# rebuild
session_booter.pop(session_id, None)
if session_id not in session_booter:
uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex
if booter_type == "shipyard":
from .booters.shipyard import ShipyardBooter
ep = sandbox_cfg.get("shipyard_endpoint", "")
token = sandbox_cfg.get("shipyard_access_token", "")
ttl = sandbox_cfg.get("shipyard_ttl", 3600)
max_sessions = sandbox_cfg.get("shipyard_max_sessions", 10)
client = ShipyardBooter(
endpoint_url=ep, access_token=token, ttl=ttl, session_num=max_sessions
)
elif booter_type == "boxlite":
from .booters.boxlite import BoxliteBooter
client = BoxliteBooter()
else:
raise ValueError(f"Unknown booter type: {booter_type}")
try:
await client.boot(uuid_str)
except Exception as e:
logger.error(f"Error booting sandbox for session {session_id}: {e}")
raise e
session_booter[session_id] = client
return session_booter[session_id]
+10
View File
@@ -0,0 +1,10 @@
from .fs import FileDownloadTool, FileUploadTool
from .python import PythonTool
from .shell import ExecuteShellTool
__all__ = [
"FileUploadTool",
"PythonTool",
"ExecuteShellTool",
"FileDownloadTool",
]
+188
View File
@@ -0,0 +1,188 @@
import os
from dataclasses import dataclass, field
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.message.components import File
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from ..sandbox_client import get_booter
# @dataclass
# class CreateFileTool(FunctionTool):
# name: str = "astrbot_create_file"
# description: str = "Create a new file in the sandbox."
# parameters: dict = field(
# default_factory=lambda: {
# "type": "object",
# "properties": {
# "path": {
# "path": "string",
# "description": "The path where the file should be created, relative to the sandbox root. Must not use absolute paths or traverse outside the sandbox.",
# },
# "content": {
# "type": "string",
# "description": "The content to write into the file.",
# },
# },
# "required": ["path", "content"],
# }
# )
# async def call(
# self, context: ContextWrapper[AstrAgentContext], path: str, content: str
# ) -> ToolExecResult:
# sb = await get_booter(
# context.context.context,
# context.context.event.unified_msg_origin,
# )
# try:
# result = await sb.fs.create_file(path, content)
# return json.dumps(result)
# except Exception as e:
# return f"Error creating file: {str(e)}"
# @dataclass
# class ReadFileTool(FunctionTool):
# name: str = "astrbot_read_file"
# description: str = "Read the content of a file in the sandbox."
# parameters: dict = field(
# default_factory=lambda: {
# "type": "object",
# "properties": {
# "path": {
# "type": "string",
# "description": "The path of the file to read, relative to the sandbox root. Must not use absolute paths or traverse outside the sandbox.",
# },
# },
# "required": ["path"],
# }
# )
# async def call(self, context: ContextWrapper[AstrAgentContext], path: str):
# sb = await get_booter(
# context.context.context,
# context.context.event.unified_msg_origin,
# )
# try:
# result = await sb.fs.read_file(path)
# return result
# except Exception as e:
# return f"Error reading file: {str(e)}"
@dataclass
class FileUploadTool(FunctionTool):
name: str = "astrbot_upload_file"
description: str = "Upload a local file to the sandbox. The file must exist on the local filesystem."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"local_path": {
"type": "string",
"description": "The local file path to upload. This must be an absolute path to an existing file on the local filesystem.",
},
# "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,
):
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)}"
@dataclass
class FileDownloadTool(FunctionTool):
name: str = "astrbot_download_file"
description: str = "Download a file from the sandbox. Only call this when user explicitly need you to download a file."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"remote_path": {
"type": "string",
"description": "The path of the file in the sandbox to download.",
}
},
"required": ["remote_path"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
remote_path: str,
) -> ToolExecResult:
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(), 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}")
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}"
except Exception as e:
logger.error(f"Error downloading file {remote_path}: {e}")
return f"Error downloading file: {str(e)}"
+74
View File
@@ -0,0 +1,74 @@
from dataclasses import dataclass, field
import mcp
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.sandbox.sandbox_client import get_booter
@dataclass
class PythonTool(FunctionTool):
name: str = "astrbot_execute_ipython"
description: str = "Execute a command in an IPython shell."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "The Python code to execute.",
},
"silent": {
"type": "boolean",
"description": "Whether to suppress the output of the code execution.",
"default": False,
},
},
"required": ["code"],
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
) -> ToolExecResult:
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
try:
result = await sb.python.exec(code, silent=silent)
data = result.get("data", {})
output = data.get("output", {})
error = data.get("error", "")
images: list[dict] = output.get("images", [])
text: str = output.get("text", "")
resp = mcp.types.CallToolResult(content=[])
if error:
resp.content.append(
mcp.types.TextContent(type="text", text=f"error: {error}")
)
if images:
for img in images:
resp.content.append(
mcp.types.ImageContent(
type="image", data=img["image/png"], mimeType="image/png"
)
)
if text:
resp.content.append(mcp.types.TextContent(type="text", text=text))
if not resp.content:
resp.content.append(
mcp.types.TextContent(type="text", text="No output.")
)
return resp
except Exception as e:
return f"Error executing code: {str(e)}"
+55
View File
@@ -0,0 +1,55 @@
import json
from dataclasses import dataclass, field
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 ..sandbox_client import get_booter
@dataclass
class ExecuteShellTool(FunctionTool):
name: str = "astrbot_execute_shell"
description: str = "Execute a command in the shell."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute. Equal to 'cd {working_dir} && {your_command}'.",
},
"background": {
"type": "boolean",
"description": "Whether to run the command in the background.",
"default": False,
},
"env": {
"type": "object",
"description": "Optional environment variables to set for the file creation process.",
"additionalProperties": {"type": "string"},
"default": {},
},
},
"required": ["command"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
command: str,
background: bool = False,
env: dict = {},
) -> ToolExecResult:
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)
except Exception as e:
return f"Error executing command: {str(e)}"
+10
View File
@@ -296,6 +296,8 @@ class ChatRoute(Route):
# 构建用户消息段(包含 path 用于传递给 adapter
message_parts = await self._build_user_message_parts(message)
message_id = str(uuid.uuid4())
async def stream():
client_disconnected = False
accumulated_parts = []
@@ -319,6 +321,13 @@ class ChatRoute(Route):
if not result:
continue
if (
"message_id" in result
and result["message_id"] != message_id
):
logger.warning("webchat stream message_id mismatch")
continue
result_text = result["data"]
msg_type = result.get("type")
streaming = result.get("streaming", False)
@@ -456,6 +465,7 @@ class ChatRoute(Route):
"selected_provider": selected_provider,
"selected_model": selected_model,
"enable_streaming": enable_streaming,
"message_id": message_id,
},
),
)
+1 -1
View File
@@ -28,7 +28,7 @@
"katex": "^0.16.27",
"lodash": "4.17.21",
"markdown-it": "^14.1.0",
"markstream-vue": "0.0.3-beta.7",
"markstream-vue": "^0.0.6-beta.1",
"mermaid": "^11.12.2",
"pinia": "2.1.6",
"pinyin-pro": "^3.26.0",
+186 -27
View File
@@ -28,7 +28,7 @@
<div v-else-if="part.type === 'image' && part.embedded_url" class="image-attachments">
<div class="image-attachment">
<img :src="part.embedded_url" class="attached-image"
@click="$emit('openImagePreview', part.embedded_url)" />
@click="openImagePreview(part.embedded_url)" />
</div>
</div>
@@ -147,24 +147,44 @@
borderTopColor: 'rgba(100, 140, 200, 0.3)',
backgroundColor: 'rgba(30, 45, 70, 0.5)'
} : {}">
<div class="tool-call-detail-row">
<span class="detail-label">ID:</span>
<code class="detail-value"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ toolCall.id
}}</code>
</div>
<div class="tool-call-detail-row">
<span class="detail-label">Args:</span>
<pre class="detail-value detail-json"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{
JSON.stringify(toolCall.args, null, 2) }}</pre>
</div>
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre class="detail-value detail-json detail-result"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ formatToolResult(toolCall.result) }}
<!-- Special rendering for iPython tool -->
<template v-if="isIPythonTool(toolCall)">
<div class="ipython-code-container">
<!-- <div class="detail-label ipython-label">Code:</div> -->
<div v-if="shikiReady && getIPythonCode(toolCall)"
class="ipython-code-highlighted"
v-html="highlightIPythonCode(getIPythonCode(toolCall))"></div>
<pre v-else class="detail-value detail-json"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ getIPythonCode(toolCall) || 'No code available' }}</pre>
</div>
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre class="detail-value detail-json detail-result"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ formatToolResult(toolCall.result) }}</pre>
</div>
</template>
<!-- Default rendering for other tools -->
<template v-else>
<div class="tool-call-detail-row">
<span class="detail-label">ID:</span>
<code class="detail-value"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ toolCall.id
}}</code>
</div>
<div class="tool-call-detail-row">
<span class="detail-label">Args:</span>
<pre class="detail-value detail-json"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{
JSON.stringify(toolCall.args, null, 2) }}</pre>
</div>
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre class="detail-value detail-json detail-result"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ formatToolResult(toolCall.result) }}
</pre>
</div>
</div>
</template>
</div>
</div>
</div>
@@ -178,7 +198,7 @@
<div v-else-if="part.type === 'image' && part.embedded_url" class="embedded-images">
<div class="embedded-image">
<img :src="part.embedded_url" class="bot-embedded-image"
@click="$emit('openImagePreview', part.embedded_url)" />
@click="openImagePreview(part.embedded_url)" />
</div>
</div>
@@ -289,6 +309,13 @@
</v-btn>
</div>
</div>
<!-- 图片预览 Overlay -->
<v-overlay v-model="imagePreview.show" class="image-preview-overlay" @click="closeImagePreview">
<div class="image-preview-container" @click.stop>
<img :src="imagePreview.url" class="preview-image" @click="closeImagePreview" />
</div>
</v-overlay>
</template>
<script>
@@ -298,6 +325,7 @@ import 'markstream-vue/index.css'
import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github.css';
import axios from 'axios';
import { createHighlighter } from 'shiki';
enableKatex();
enableMermaid();
@@ -351,15 +379,24 @@ export default {
content: '',
messageIndex: null,
position: { top: 0, left: 0 }
}
},
//
imagePreview: {
show: false,
url: ''
},
// Shiki highlighter
shikiHighlighter: null,
shikiReady: false
};
},
mounted() {
async mounted() {
this.initCodeCopyButtons();
this.initImageClickEvents();
this.addScrollListener();
this.scrollToBottom();
this.startElapsedTimeTimer();
await this.initShiki();
},
updated() {
this.initCodeCopyButtons();
@@ -676,7 +713,7 @@ export default {
if (!img.hasAttribute('data-click-enabled')) {
img.style.cursor = 'pointer';
img.setAttribute('data-click-enabled', 'true');
img.onclick = () => this.$emit('openImagePreview', img.src);
img.onclick = () => this.openImagePreview(img.src);
}
});
});
@@ -877,6 +914,66 @@ export default {
formatTTFT(ttft) {
if (!ttft || ttft <= 0) return '';
return this.formatDuration(ttft);
},
//
openImagePreview(url) {
this.imagePreview.url = url;
this.imagePreview.show = true;
},
//
closeImagePreview() {
this.imagePreview.show = false;
setTimeout(() => {
this.imagePreview.url = '';
}, 300);
},
// Initialize Shiki highlighter
async initShiki() {
try {
this.shikiHighlighter = await createHighlighter({
themes: ['nord', 'github-light'],
langs: ['python']
});
this.shikiReady = true;
} catch (err) {
console.error('Failed to initialize Shiki:', err);
}
},
// Check if tool is iPython executor
isIPythonTool(toolCall) {
return toolCall.name === 'astrbot_execute_ipython';
},
// Get iPython code from tool args
getIPythonCode(toolCall) {
try {
if (toolCall.args && toolCall.args.code) {
return toolCall.args.code;
}
} catch (err) {
console.error('Failed to get iPython code:', err);
}
return null;
},
// Highlight iPython code with Shiki
highlightIPythonCode(code) {
if (!this.shikiReady || !this.shikiHighlighter || !code) {
return '';
}
try {
return this.shikiHighlighter.codeToHtml(code, {
lang: 'python',
theme: this.isDark ? 'nord' : 'github-light'
});
} catch (err) {
console.error('Failed to highlight code:', err);
return `<pre><code>${code}</code></pre>`;
}
}
}
}
@@ -1268,10 +1365,10 @@ export default {
}
.bot-embedded-image {
max-width: 40%;
max-width: 55%;
width: auto;
height: auto;
border-radius: 8px;
border-radius: 4px;
cursor: pointer;
transition: transform 0.2s ease;
}
@@ -1423,12 +1520,14 @@ export default {
overflow: hidden;
background-color: #eff3f6;
margin: 8px 0px;
max-width: 300px;
transition: max-width 0.1s ease;
width: fit-content;
min-width: 320px;
max-width: 100%;
transition: all 0.1s ease;
}
.tool-call-card.expanded {
max-width: 100%;
width: 100%;
}
.tool-call-header {
@@ -1595,6 +1694,34 @@ export default {
max-height: 300px;
background-color: transparent;
}
/* iPython Tool Special Styles */
.ipython-code-container {
margin-bottom: 12px;
}
.ipython-label {
margin-bottom: 8px;
}
.ipython-code-highlighted {
border-radius: 6px;
overflow-x: auto;
font-size: 13px;
line-height: 1.5;
}
.ipython-code-highlighted :deep(pre) {
margin: 0;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
.ipython-code-highlighted :deep(code) {
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 13px;
}
</style>
<style>
@@ -1635,4 +1762,36 @@ export default {
font-family: 'Fira Code', 'Consolas', monospace;
color: var(--v-theme-primaryText);
}
/* 图片预览样式 */
.image-preview-overlay {
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.image-preview-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.preview-image {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
cursor: pointer;
}
.close-preview-btn {
position: fixed;
top: 20px;
right: 20px;
}
</style>
@@ -14,6 +14,12 @@
<v-expand-transition>
<div v-show="expanded" style="padding: 0 8px;">
<v-list density="compact" nav class="project-list" style="background-color: transparent;">
<v-list-item @click="$emit('createProject')" class="create-project-item" rounded="lg">
<template v-slot:prepend>
<span class="project-emoji"><v-icon size="small">mdi-plus</v-icon></span>
</template>
<v-list-item-title style="font-size: 13px;">{{ tm('project.create') }}</v-list-item-title>
</v-list-item>
<v-list-item v-for="project in projects" :key="project.project_id"
@click="$emit('selectProject', project.project_id)" rounded="lg" class="project-item">
<template v-slot:prepend>
@@ -29,12 +35,6 @@
</div>
</template>
</v-list-item>
<v-list-item @click="$emit('createProject')" class="create-project-item" rounded="lg">
<template v-slot:prepend>
<v-icon size="small">mdi-plus</v-icon>
</template>
<v-list-item-title style="font-size: 13px;">{{ tm('project.create') }}</v-list-item-title>
</v-list-item>
</v-list>
</div>
</v-expand-transition>
@@ -133,6 +133,36 @@
}
}
},
"sandbox": {
"description": "Agent Sandbox Env(Beta)",
"provider_settings": {
"sandbox": {
"enable": {
"description": "Enable Sandbox Env",
"hint": "When enabled, Agent can use tools and resources in the sandbox environment, such as Python tool, Shell, etc."
},
"booter": {
"description": "Sandbox Environment Driver"
},
"shipyard_endpoint": {
"description": "Shipyard API Endpoint",
"hint": "API access address for Shipyard service."
},
"shipyard_access_token": {
"description": "Shipyard Access Token",
"hint": "Access token for accessing Shipyard service."
},
"shipyard_ttl": {
"description": "Shipyard Session TTL",
"hint": "Session time-to-live in seconds."
},
"shipyard_max_sessions": {
"description": "Shipyard Max Sessions",
"hint": "Maximum number of Shipyard sessions an instance can handle."
}
}
}
},
"truncate_and_compress": {
"description": "Context Management Strategy",
"provider_settings": {
@@ -133,6 +133,36 @@
}
}
},
"sandbox": {
"description": "Agent 沙箱环境(Beta)",
"provider_settings": {
"sandbox": {
"enable": {
"description": "启用沙箱环境",
"hint": "启用后,Agent 可以使用沙箱环境中的工具和资源,如 Python 代码执行、Shell 等。"
},
"booter": {
"description": "沙箱环境驱动器"
},
"shipyard_endpoint": {
"description": "Shipyard API Endpoint",
"hint": "Shipyard 服务的 API 访问地址。"
},
"shipyard_access_token": {
"description": "Shipyard 访问令牌",
"hint": "用于访问 Shipyard 服务的访问令牌。"
},
"shipyard_ttl": {
"description": "Shipyard Ship 存活时间(秒)",
"hint": "Shipyard 会话的生存时间(秒)。"
},
"shipyard_max_sessions": {
"description": "Shipyard Ship 会话复用上限",
"hint": "决定了一个实例承载的最大会话数量。"
}
}
}
},
"truncate_and_compress": {
"description": "上下文管理策略",
"provider_settings": {
+1
View File
@@ -60,6 +60,7 @@ dependencies = [
"markitdown-no-magika[docx,xls,xlsx]>=0.1.2",
"xinference-client",
"tenacity>=9.1.2",
"shipyard-python-sdk>=0.2.4",
]
[dependency-groups]