AstrBot 4.11.3__py3-none-any.whl → 4.12.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- astrbot/cli/__init__.py +1 -1
- astrbot/core/agent/runners/tool_loop_agent_runner.py +10 -8
- astrbot/core/config/default.py +66 -13
- astrbot/core/db/__init__.py +84 -2
- astrbot/core/db/po.py +65 -0
- astrbot/core/db/sqlite.py +225 -4
- astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +103 -49
- astrbot/core/pipeline/process_stage/utils.py +40 -0
- astrbot/core/platform/sources/discord/discord_platform_adapter.py +2 -0
- astrbot/core/platform/sources/telegram/tg_adapter.py +2 -0
- astrbot/core/platform/sources/webchat/webchat_adapter.py +3 -2
- astrbot/core/platform/sources/webchat/webchat_event.py +17 -4
- astrbot/core/provider/sources/anthropic_source.py +44 -0
- astrbot/core/sandbox/booters/base.py +31 -0
- astrbot/core/sandbox/booters/boxlite.py +186 -0
- astrbot/core/sandbox/booters/shipyard.py +67 -0
- astrbot/core/sandbox/olayer/__init__.py +5 -0
- astrbot/core/sandbox/olayer/filesystem.py +33 -0
- astrbot/core/sandbox/olayer/python.py +19 -0
- astrbot/core/sandbox/olayer/shell.py +21 -0
- astrbot/core/sandbox/sandbox_client.py +52 -0
- astrbot/core/sandbox/tools/__init__.py +10 -0
- astrbot/core/sandbox/tools/fs.py +188 -0
- astrbot/core/sandbox/tools/python.py +74 -0
- astrbot/core/sandbox/tools/shell.py +55 -0
- astrbot/core/star/context.py +162 -44
- astrbot/core/utils/metrics.py +2 -0
- astrbot/dashboard/routes/__init__.py +2 -0
- astrbot/dashboard/routes/chat.py +40 -12
- astrbot/dashboard/routes/chatui_project.py +245 -0
- astrbot/dashboard/routes/session_management.py +545 -0
- astrbot/dashboard/server.py +1 -0
- {astrbot-4.11.3.dist-info → astrbot-4.12.0.dist-info}/METADATA +2 -3
- {astrbot-4.11.3.dist-info → astrbot-4.12.0.dist-info}/RECORD +37 -28
- astrbot/builtin_stars/python_interpreter/main.py +0 -536
- astrbot/builtin_stars/python_interpreter/metadata.yaml +0 -4
- astrbot/builtin_stars/python_interpreter/requirements.txt +0 -1
- astrbot/builtin_stars/python_interpreter/shared/api.py +0 -22
- {astrbot-4.11.3.dist-info → astrbot-4.12.0.dist-info}/WHEEL +0 -0
- {astrbot-4.11.3.dist-info → astrbot-4.12.0.dist-info}/entry_points.txt +0 -0
- {astrbot-4.11.3.dist-info → astrbot-4.12.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import random
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
import boxlite
|
|
7
|
+
from shipyard.filesystem import FileSystemComponent as ShipyardFileSystemComponent
|
|
8
|
+
from shipyard.python import PythonComponent as ShipyardPythonComponent
|
|
9
|
+
from shipyard.shell import ShellComponent as ShipyardShellComponent
|
|
10
|
+
|
|
11
|
+
from astrbot.api import logger
|
|
12
|
+
|
|
13
|
+
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
|
|
14
|
+
from .base import SandboxBooter
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MockShipyardSandboxClient:
|
|
18
|
+
def __init__(self, sb_url: str) -> None:
|
|
19
|
+
self.sb_url = sb_url.rstrip("/")
|
|
20
|
+
|
|
21
|
+
async def _exec_operation(
|
|
22
|
+
self,
|
|
23
|
+
ship_id: str,
|
|
24
|
+
operation_type: str,
|
|
25
|
+
payload: dict[str, Any],
|
|
26
|
+
session_id: str,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
async with aiohttp.ClientSession() as session:
|
|
29
|
+
headers = {"X-SESSION-ID": session_id}
|
|
30
|
+
async with session.post(
|
|
31
|
+
f"{self.sb_url}/{operation_type}",
|
|
32
|
+
json=payload,
|
|
33
|
+
headers=headers,
|
|
34
|
+
) as response:
|
|
35
|
+
if response.status == 200:
|
|
36
|
+
return await response.json()
|
|
37
|
+
else:
|
|
38
|
+
error_text = await response.text()
|
|
39
|
+
raise Exception(
|
|
40
|
+
f"Failed to exec operation: {response.status} {error_text}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
async def upload_file(self, path: str, remote_path: str) -> dict:
|
|
44
|
+
"""Upload a file to the sandbox"""
|
|
45
|
+
url = f"http://{self.sb_url}/upload"
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
# Read file content
|
|
49
|
+
with open(path, "rb") as f:
|
|
50
|
+
file_content = f.read()
|
|
51
|
+
|
|
52
|
+
# Create multipart form data
|
|
53
|
+
data = aiohttp.FormData()
|
|
54
|
+
data.add_field(
|
|
55
|
+
"file",
|
|
56
|
+
file_content,
|
|
57
|
+
filename=remote_path.split("/")[-1],
|
|
58
|
+
content_type="application/octet-stream",
|
|
59
|
+
)
|
|
60
|
+
data.add_field("file_path", remote_path)
|
|
61
|
+
|
|
62
|
+
timeout = aiohttp.ClientTimeout(total=120) # 2 minutes for file upload
|
|
63
|
+
|
|
64
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
65
|
+
async with session.post(url, data=data) as response:
|
|
66
|
+
if response.status == 200:
|
|
67
|
+
return {
|
|
68
|
+
"success": True,
|
|
69
|
+
"message": "File uploaded successfully",
|
|
70
|
+
"file_path": remote_path,
|
|
71
|
+
}
|
|
72
|
+
else:
|
|
73
|
+
error_text = await response.text()
|
|
74
|
+
return {
|
|
75
|
+
"success": False,
|
|
76
|
+
"error": f"Server returned {response.status}: {error_text}",
|
|
77
|
+
"message": "File upload failed",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
except aiohttp.ClientError as e:
|
|
81
|
+
logger.error(f"Failed to upload file: {e}")
|
|
82
|
+
return {
|
|
83
|
+
"success": False,
|
|
84
|
+
"error": f"Connection error: {str(e)}",
|
|
85
|
+
"message": "File upload failed",
|
|
86
|
+
}
|
|
87
|
+
except asyncio.TimeoutError:
|
|
88
|
+
return {
|
|
89
|
+
"success": False,
|
|
90
|
+
"error": "File upload timeout",
|
|
91
|
+
"message": "File upload failed",
|
|
92
|
+
}
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
logger.error(f"File not found: {path}")
|
|
95
|
+
return {
|
|
96
|
+
"success": False,
|
|
97
|
+
"error": f"File not found: {path}",
|
|
98
|
+
"message": "File upload failed",
|
|
99
|
+
}
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.error(f"Unexpected error uploading file: {e}")
|
|
102
|
+
return {
|
|
103
|
+
"success": False,
|
|
104
|
+
"error": f"Internal error: {str(e)}",
|
|
105
|
+
"message": "File upload failed",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async def wait_healthy(self, ship_id: str, session_id: str) -> None:
|
|
109
|
+
"""Mock wait healthy"""
|
|
110
|
+
loop = 60
|
|
111
|
+
while loop > 0:
|
|
112
|
+
try:
|
|
113
|
+
logger.info(
|
|
114
|
+
f"Checking health for sandbox {ship_id} on {self.sb_url}..."
|
|
115
|
+
)
|
|
116
|
+
url = f"{self.sb_url}/health"
|
|
117
|
+
async with aiohttp.ClientSession() as session:
|
|
118
|
+
async with session.get(url) as response:
|
|
119
|
+
if response.status == 200:
|
|
120
|
+
logger.info(f"Sandbox {ship_id} is healthy")
|
|
121
|
+
return
|
|
122
|
+
except Exception:
|
|
123
|
+
await asyncio.sleep(1)
|
|
124
|
+
loop -= 1
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class BoxliteBooter(SandboxBooter):
|
|
128
|
+
async def boot(self, session_id: str) -> None:
|
|
129
|
+
logger.info(
|
|
130
|
+
f"Booting(Boxlite) for session: {session_id}, this may take a while..."
|
|
131
|
+
)
|
|
132
|
+
random_port = random.randint(20000, 30000)
|
|
133
|
+
self.box = boxlite.SimpleBox(
|
|
134
|
+
image="soulter/shipyard-ship",
|
|
135
|
+
memory_mib=512,
|
|
136
|
+
cpus=1,
|
|
137
|
+
ports=[
|
|
138
|
+
{
|
|
139
|
+
"host_port": random_port,
|
|
140
|
+
"guest_port": 8123,
|
|
141
|
+
}
|
|
142
|
+
],
|
|
143
|
+
)
|
|
144
|
+
await self.box.start()
|
|
145
|
+
logger.info(f"Boxlite booter started for session: {session_id}")
|
|
146
|
+
self.mocked = MockShipyardSandboxClient(
|
|
147
|
+
sb_url=f"http://127.0.0.1:{random_port}"
|
|
148
|
+
)
|
|
149
|
+
self._fs = ShipyardFileSystemComponent(
|
|
150
|
+
client=self.mocked, # type: ignore
|
|
151
|
+
ship_id=self.box.id,
|
|
152
|
+
session_id=session_id,
|
|
153
|
+
)
|
|
154
|
+
self._python = ShipyardPythonComponent(
|
|
155
|
+
client=self.mocked, # type: ignore
|
|
156
|
+
ship_id=self.box.id,
|
|
157
|
+
session_id=session_id,
|
|
158
|
+
)
|
|
159
|
+
self._shell = ShipyardShellComponent(
|
|
160
|
+
client=self.mocked, # type: ignore
|
|
161
|
+
ship_id=self.box.id,
|
|
162
|
+
session_id=session_id,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
await self.mocked.wait_healthy(self.box.id, session_id)
|
|
166
|
+
|
|
167
|
+
async def shutdown(self) -> None:
|
|
168
|
+
logger.info(f"Shutting down Boxlite booter for ship: {self.box.id}")
|
|
169
|
+
self.box.shutdown()
|
|
170
|
+
logger.info(f"Boxlite booter for ship: {self.box.id} stopped")
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def fs(self) -> FileSystemComponent:
|
|
174
|
+
return self._fs
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def python(self) -> PythonComponent:
|
|
178
|
+
return self._python
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def shell(self) -> ShellComponent:
|
|
182
|
+
return self._shell
|
|
183
|
+
|
|
184
|
+
async def upload_file(self, path: str, file_name: str) -> dict:
|
|
185
|
+
"""Upload file to sandbox"""
|
|
186
|
+
return await self.mocked.upload_file(path, file_name)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from shipyard import ShipyardClient, Spec
|
|
2
|
+
|
|
3
|
+
from astrbot.api import logger
|
|
4
|
+
|
|
5
|
+
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
|
|
6
|
+
from .base import SandboxBooter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ShipyardBooter(SandboxBooter):
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
endpoint_url: str,
|
|
13
|
+
access_token: str,
|
|
14
|
+
ttl: int = 3600,
|
|
15
|
+
session_num: int = 10,
|
|
16
|
+
) -> None:
|
|
17
|
+
self._sandbox_client = ShipyardClient(
|
|
18
|
+
endpoint_url=endpoint_url, access_token=access_token
|
|
19
|
+
)
|
|
20
|
+
self._ttl = ttl
|
|
21
|
+
self._session_num = session_num
|
|
22
|
+
|
|
23
|
+
async def boot(self, session_id: str) -> None:
|
|
24
|
+
ship = await self._sandbox_client.create_ship(
|
|
25
|
+
ttl=self._ttl,
|
|
26
|
+
spec=Spec(cpus=1.0, memory="512m"),
|
|
27
|
+
max_session_num=self._session_num,
|
|
28
|
+
session_id=session_id,
|
|
29
|
+
)
|
|
30
|
+
logger.info(f"Got sandbox ship: {ship.id} for session: {session_id}")
|
|
31
|
+
self._ship = ship
|
|
32
|
+
|
|
33
|
+
async def shutdown(self) -> None:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def fs(self) -> FileSystemComponent:
|
|
38
|
+
return self._ship.fs
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def python(self) -> PythonComponent:
|
|
42
|
+
return self._ship.python
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def shell(self) -> ShellComponent:
|
|
46
|
+
return self._ship.shell
|
|
47
|
+
|
|
48
|
+
async def upload_file(self, path: str, file_name: str) -> dict:
|
|
49
|
+
"""Upload file to sandbox"""
|
|
50
|
+
return await self._ship.upload_file(path, file_name)
|
|
51
|
+
|
|
52
|
+
async def download_file(self, remote_path: str, local_path: str):
|
|
53
|
+
"""Download file from sandbox."""
|
|
54
|
+
return await self._ship.download_file(remote_path, local_path)
|
|
55
|
+
|
|
56
|
+
async def available(self) -> bool:
|
|
57
|
+
"""Check if the sandbox is available."""
|
|
58
|
+
try:
|
|
59
|
+
ship_id = self._ship.id
|
|
60
|
+
data = await self._sandbox_client.get_ship(ship_id)
|
|
61
|
+
if not data:
|
|
62
|
+
return False
|
|
63
|
+
health = bool(data.get("status", 0) == 1)
|
|
64
|
+
return health
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.error(f"Error checking Shipyard sandbox availability: {e}")
|
|
67
|
+
return False
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File system component
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FileSystemComponent(Protocol):
|
|
9
|
+
async def create_file(
|
|
10
|
+
self, path: str, content: str = "", mode: int = 0o644
|
|
11
|
+
) -> dict[str, Any]:
|
|
12
|
+
"""Create a file with the specified content"""
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
|
|
16
|
+
"""Read file content"""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
async def write_file(
|
|
20
|
+
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
|
|
21
|
+
) -> dict[str, Any]:
|
|
22
|
+
"""Write content to file"""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
async def delete_file(self, path: str) -> dict[str, Any]:
|
|
26
|
+
"""Delete file or directory"""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
async def list_dir(
|
|
30
|
+
self, path: str = ".", show_hidden: bool = False
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
"""List directory contents"""
|
|
33
|
+
...
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Python/IPython component
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PythonComponent(Protocol):
|
|
9
|
+
"""Python/IPython operations component"""
|
|
10
|
+
|
|
11
|
+
async def exec(
|
|
12
|
+
self,
|
|
13
|
+
code: str,
|
|
14
|
+
kernel_id: str | None = None,
|
|
15
|
+
timeout: int = 30,
|
|
16
|
+
silent: bool = False,
|
|
17
|
+
) -> dict[str, Any]:
|
|
18
|
+
"""Execute Python code"""
|
|
19
|
+
...
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shell component
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ShellComponent(Protocol):
|
|
9
|
+
"""Shell operations component"""
|
|
10
|
+
|
|
11
|
+
async def exec(
|
|
12
|
+
self,
|
|
13
|
+
command: str,
|
|
14
|
+
cwd: str | None = None,
|
|
15
|
+
env: dict[str, str] | None = None,
|
|
16
|
+
timeout: int | None = 30,
|
|
17
|
+
shell: bool = True,
|
|
18
|
+
background: bool = False,
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
"""Execute shell command"""
|
|
21
|
+
...
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from astrbot.api import logger
|
|
4
|
+
from astrbot.core.star.context import Context
|
|
5
|
+
|
|
6
|
+
from .booters.base import SandboxBooter
|
|
7
|
+
|
|
8
|
+
session_booter: dict[str, SandboxBooter] = {}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def get_booter(
|
|
12
|
+
context: Context,
|
|
13
|
+
session_id: str,
|
|
14
|
+
) -> SandboxBooter:
|
|
15
|
+
config = context.get_config(umo=session_id)
|
|
16
|
+
|
|
17
|
+
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
|
|
18
|
+
booter_type = sandbox_cfg.get("booter", "shipyard")
|
|
19
|
+
|
|
20
|
+
if session_id in session_booter:
|
|
21
|
+
booter = session_booter[session_id]
|
|
22
|
+
if not await booter.available():
|
|
23
|
+
# rebuild
|
|
24
|
+
session_booter.pop(session_id, None)
|
|
25
|
+
if session_id not in session_booter:
|
|
26
|
+
uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex
|
|
27
|
+
if booter_type == "shipyard":
|
|
28
|
+
from .booters.shipyard import ShipyardBooter
|
|
29
|
+
|
|
30
|
+
ep = sandbox_cfg.get("shipyard_endpoint", "")
|
|
31
|
+
token = sandbox_cfg.get("shipyard_access_token", "")
|
|
32
|
+
ttl = sandbox_cfg.get("shipyard_ttl", 3600)
|
|
33
|
+
max_sessions = sandbox_cfg.get("shipyard_max_sessions", 10)
|
|
34
|
+
|
|
35
|
+
client = ShipyardBooter(
|
|
36
|
+
endpoint_url=ep, access_token=token, ttl=ttl, session_num=max_sessions
|
|
37
|
+
)
|
|
38
|
+
elif booter_type == "boxlite":
|
|
39
|
+
from .booters.boxlite import BoxliteBooter
|
|
40
|
+
|
|
41
|
+
client = BoxliteBooter()
|
|
42
|
+
else:
|
|
43
|
+
raise ValueError(f"Unknown booter type: {booter_type}")
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
await client.boot(uuid_str)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.error(f"Error booting sandbox for session {session_id}: {e}")
|
|
49
|
+
raise e
|
|
50
|
+
|
|
51
|
+
session_booter[session_id] = client
|
|
52
|
+
return session_booter[session_id]
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
from astrbot.api import FunctionTool, logger
|
|
5
|
+
from astrbot.api.event import MessageChain
|
|
6
|
+
from astrbot.core.agent.run_context import ContextWrapper
|
|
7
|
+
from astrbot.core.agent.tool import ToolExecResult
|
|
8
|
+
from astrbot.core.astr_agent_context import AstrAgentContext
|
|
9
|
+
from astrbot.core.message.components import File
|
|
10
|
+
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
|
11
|
+
|
|
12
|
+
from ..sandbox_client import get_booter
|
|
13
|
+
|
|
14
|
+
# @dataclass
|
|
15
|
+
# class CreateFileTool(FunctionTool):
|
|
16
|
+
# name: str = "astrbot_create_file"
|
|
17
|
+
# description: str = "Create a new file in the sandbox."
|
|
18
|
+
# parameters: dict = field(
|
|
19
|
+
# default_factory=lambda: {
|
|
20
|
+
# "type": "object",
|
|
21
|
+
# "properties": {
|
|
22
|
+
# "path": {
|
|
23
|
+
# "path": "string",
|
|
24
|
+
# "description": "The path where the file should be created, relative to the sandbox root. Must not use absolute paths or traverse outside the sandbox.",
|
|
25
|
+
# },
|
|
26
|
+
# "content": {
|
|
27
|
+
# "type": "string",
|
|
28
|
+
# "description": "The content to write into the file.",
|
|
29
|
+
# },
|
|
30
|
+
# },
|
|
31
|
+
# "required": ["path", "content"],
|
|
32
|
+
# }
|
|
33
|
+
# )
|
|
34
|
+
|
|
35
|
+
# async def call(
|
|
36
|
+
# self, context: ContextWrapper[AstrAgentContext], path: str, content: str
|
|
37
|
+
# ) -> ToolExecResult:
|
|
38
|
+
# sb = await get_booter(
|
|
39
|
+
# context.context.context,
|
|
40
|
+
# context.context.event.unified_msg_origin,
|
|
41
|
+
# )
|
|
42
|
+
# try:
|
|
43
|
+
# result = await sb.fs.create_file(path, content)
|
|
44
|
+
# return json.dumps(result)
|
|
45
|
+
# except Exception as e:
|
|
46
|
+
# return f"Error creating file: {str(e)}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# @dataclass
|
|
50
|
+
# class ReadFileTool(FunctionTool):
|
|
51
|
+
# name: str = "astrbot_read_file"
|
|
52
|
+
# description: str = "Read the content of a file in the sandbox."
|
|
53
|
+
# parameters: dict = field(
|
|
54
|
+
# default_factory=lambda: {
|
|
55
|
+
# "type": "object",
|
|
56
|
+
# "properties": {
|
|
57
|
+
# "path": {
|
|
58
|
+
# "type": "string",
|
|
59
|
+
# "description": "The path of the file to read, relative to the sandbox root. Must not use absolute paths or traverse outside the sandbox.",
|
|
60
|
+
# },
|
|
61
|
+
# },
|
|
62
|
+
# "required": ["path"],
|
|
63
|
+
# }
|
|
64
|
+
# )
|
|
65
|
+
|
|
66
|
+
# async def call(self, context: ContextWrapper[AstrAgentContext], path: str):
|
|
67
|
+
# sb = await get_booter(
|
|
68
|
+
# context.context.context,
|
|
69
|
+
# context.context.event.unified_msg_origin,
|
|
70
|
+
# )
|
|
71
|
+
# try:
|
|
72
|
+
# result = await sb.fs.read_file(path)
|
|
73
|
+
# return result
|
|
74
|
+
# except Exception as e:
|
|
75
|
+
# return f"Error reading file: {str(e)}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class FileUploadTool(FunctionTool):
|
|
80
|
+
name: str = "astrbot_upload_file"
|
|
81
|
+
description: str = "Upload a local file to the sandbox. The file must exist on the local filesystem."
|
|
82
|
+
parameters: dict = field(
|
|
83
|
+
default_factory=lambda: {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"properties": {
|
|
86
|
+
"local_path": {
|
|
87
|
+
"type": "string",
|
|
88
|
+
"description": "The local file path to upload. This must be an absolute path to an existing file on the local filesystem.",
|
|
89
|
+
},
|
|
90
|
+
# "remote_path": {
|
|
91
|
+
# "type": "string",
|
|
92
|
+
# "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.",
|
|
93
|
+
# },
|
|
94
|
+
},
|
|
95
|
+
"required": ["local_path"],
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def call(
|
|
100
|
+
self,
|
|
101
|
+
context: ContextWrapper[AstrAgentContext],
|
|
102
|
+
local_path: str,
|
|
103
|
+
):
|
|
104
|
+
sb = await get_booter(
|
|
105
|
+
context.context.context,
|
|
106
|
+
context.context.event.unified_msg_origin,
|
|
107
|
+
)
|
|
108
|
+
try:
|
|
109
|
+
# Check if file exists
|
|
110
|
+
if not os.path.exists(local_path):
|
|
111
|
+
return f"Error: File does not exist: {local_path}"
|
|
112
|
+
|
|
113
|
+
if not os.path.isfile(local_path):
|
|
114
|
+
return f"Error: Path is not a file: {local_path}"
|
|
115
|
+
|
|
116
|
+
# Use basename if sandbox_filename is not provided
|
|
117
|
+
remote_path = os.path.basename(local_path)
|
|
118
|
+
|
|
119
|
+
# Upload file to sandbox
|
|
120
|
+
result = await sb.upload_file(local_path, remote_path)
|
|
121
|
+
logger.debug(f"Upload result: {result}")
|
|
122
|
+
success = result.get("success", False)
|
|
123
|
+
|
|
124
|
+
if not success:
|
|
125
|
+
return f"Error uploading file: {result.get('message', 'Unknown error')}"
|
|
126
|
+
|
|
127
|
+
file_path = result.get("file_path", "")
|
|
128
|
+
logger.info(f"File {local_path} uploaded to sandbox at {file_path}")
|
|
129
|
+
|
|
130
|
+
return f"File uploaded successfully to {file_path}"
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.error(f"Error uploading file {local_path}: {e}")
|
|
133
|
+
return f"Error uploading file: {str(e)}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class FileDownloadTool(FunctionTool):
|
|
138
|
+
name: str = "astrbot_download_file"
|
|
139
|
+
description: str = "Download a file from the sandbox. Only call this when user explicitly need you to download a file."
|
|
140
|
+
parameters: dict = field(
|
|
141
|
+
default_factory=lambda: {
|
|
142
|
+
"type": "object",
|
|
143
|
+
"properties": {
|
|
144
|
+
"remote_path": {
|
|
145
|
+
"type": "string",
|
|
146
|
+
"description": "The path of the file in the sandbox to download.",
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
"required": ["remote_path"],
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
async def call(
|
|
154
|
+
self,
|
|
155
|
+
context: ContextWrapper[AstrAgentContext],
|
|
156
|
+
remote_path: str,
|
|
157
|
+
) -> ToolExecResult:
|
|
158
|
+
sb = await get_booter(
|
|
159
|
+
context.context.context,
|
|
160
|
+
context.context.event.unified_msg_origin,
|
|
161
|
+
)
|
|
162
|
+
try:
|
|
163
|
+
name = os.path.basename(remote_path)
|
|
164
|
+
|
|
165
|
+
local_path = os.path.join(get_astrbot_temp_path(), name)
|
|
166
|
+
|
|
167
|
+
# Download file from sandbox
|
|
168
|
+
await sb.download_file(remote_path, local_path)
|
|
169
|
+
logger.info(f"File {remote_path} downloaded from sandbox to {local_path}")
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
name = os.path.basename(local_path)
|
|
173
|
+
await context.context.event.send(
|
|
174
|
+
MessageChain(chain=[File(name=name, file=local_path)])
|
|
175
|
+
)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.error(f"Error sending file message: {e}")
|
|
178
|
+
|
|
179
|
+
# remove
|
|
180
|
+
try:
|
|
181
|
+
os.remove(local_path)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.error(f"Error removing temp file {local_path}: {e}")
|
|
184
|
+
|
|
185
|
+
return f"File downloaded successfully to {local_path}"
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.error(f"Error downloading file {remote_path}: {e}")
|
|
188
|
+
return f"Error downloading file: {str(e)}"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
import mcp
|
|
4
|
+
|
|
5
|
+
from astrbot.api import FunctionTool
|
|
6
|
+
from astrbot.core.agent.run_context import ContextWrapper
|
|
7
|
+
from astrbot.core.agent.tool import ToolExecResult
|
|
8
|
+
from astrbot.core.astr_agent_context import AstrAgentContext
|
|
9
|
+
from astrbot.core.sandbox.sandbox_client import get_booter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class PythonTool(FunctionTool):
|
|
14
|
+
name: str = "astrbot_execute_ipython"
|
|
15
|
+
description: str = "Execute a command in an IPython shell."
|
|
16
|
+
parameters: dict = field(
|
|
17
|
+
default_factory=lambda: {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"properties": {
|
|
20
|
+
"code": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "The Python code to execute.",
|
|
23
|
+
},
|
|
24
|
+
"silent": {
|
|
25
|
+
"type": "boolean",
|
|
26
|
+
"description": "Whether to suppress the output of the code execution.",
|
|
27
|
+
"default": False,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
"required": ["code"],
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
async def call(
|
|
35
|
+
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
|
|
36
|
+
) -> ToolExecResult:
|
|
37
|
+
sb = await get_booter(
|
|
38
|
+
context.context.context,
|
|
39
|
+
context.context.event.unified_msg_origin,
|
|
40
|
+
)
|
|
41
|
+
try:
|
|
42
|
+
result = await sb.python.exec(code, silent=silent)
|
|
43
|
+
data = result.get("data", {})
|
|
44
|
+
output = data.get("output", {})
|
|
45
|
+
error = data.get("error", "")
|
|
46
|
+
images: list[dict] = output.get("images", [])
|
|
47
|
+
text: str = output.get("text", "")
|
|
48
|
+
|
|
49
|
+
resp = mcp.types.CallToolResult(content=[])
|
|
50
|
+
|
|
51
|
+
if error:
|
|
52
|
+
resp.content.append(
|
|
53
|
+
mcp.types.TextContent(type="text", text=f"error: {error}")
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if images:
|
|
57
|
+
for img in images:
|
|
58
|
+
resp.content.append(
|
|
59
|
+
mcp.types.ImageContent(
|
|
60
|
+
type="image", data=img["image/png"], mimeType="image/png"
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
if text:
|
|
64
|
+
resp.content.append(mcp.types.TextContent(type="text", text=text))
|
|
65
|
+
|
|
66
|
+
if not resp.content:
|
|
67
|
+
resp.content.append(
|
|
68
|
+
mcp.types.TextContent(type="text", text="No output.")
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return resp
|
|
72
|
+
|
|
73
|
+
except Exception as e:
|
|
74
|
+
return f"Error executing code: {str(e)}"
|