AstrBot 4.11.4__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.
Files changed (40) hide show
  1. astrbot/cli/__init__.py +1 -1
  2. astrbot/core/agent/runners/tool_loop_agent_runner.py +10 -8
  3. astrbot/core/config/default.py +66 -2
  4. astrbot/core/db/__init__.py +84 -2
  5. astrbot/core/db/po.py +65 -0
  6. astrbot/core/db/sqlite.py +225 -4
  7. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +103 -49
  8. astrbot/core/pipeline/process_stage/utils.py +40 -0
  9. astrbot/core/platform/sources/discord/discord_platform_adapter.py +2 -0
  10. astrbot/core/platform/sources/telegram/tg_adapter.py +2 -0
  11. astrbot/core/platform/sources/webchat/webchat_adapter.py +3 -2
  12. astrbot/core/platform/sources/webchat/webchat_event.py +17 -4
  13. astrbot/core/provider/sources/anthropic_source.py +44 -0
  14. astrbot/core/sandbox/booters/base.py +31 -0
  15. astrbot/core/sandbox/booters/boxlite.py +186 -0
  16. astrbot/core/sandbox/booters/shipyard.py +67 -0
  17. astrbot/core/sandbox/olayer/__init__.py +5 -0
  18. astrbot/core/sandbox/olayer/filesystem.py +33 -0
  19. astrbot/core/sandbox/olayer/python.py +19 -0
  20. astrbot/core/sandbox/olayer/shell.py +21 -0
  21. astrbot/core/sandbox/sandbox_client.py +52 -0
  22. astrbot/core/sandbox/tools/__init__.py +10 -0
  23. astrbot/core/sandbox/tools/fs.py +188 -0
  24. astrbot/core/sandbox/tools/python.py +74 -0
  25. astrbot/core/sandbox/tools/shell.py +55 -0
  26. astrbot/core/star/context.py +162 -44
  27. astrbot/dashboard/routes/__init__.py +2 -0
  28. astrbot/dashboard/routes/chat.py +40 -12
  29. astrbot/dashboard/routes/chatui_project.py +245 -0
  30. astrbot/dashboard/routes/session_management.py +545 -0
  31. astrbot/dashboard/server.py +1 -0
  32. {astrbot-4.11.4.dist-info → astrbot-4.12.0.dist-info}/METADATA +2 -1
  33. {astrbot-4.11.4.dist-info → astrbot-4.12.0.dist-info}/RECORD +36 -27
  34. astrbot/builtin_stars/python_interpreter/main.py +0 -536
  35. astrbot/builtin_stars/python_interpreter/metadata.yaml +0 -4
  36. astrbot/builtin_stars/python_interpreter/requirements.txt +0 -1
  37. astrbot/builtin_stars/python_interpreter/shared/api.py +0 -22
  38. {astrbot-4.11.4.dist-info → astrbot-4.12.0.dist-info}/WHEEL +0 -0
  39. {astrbot-4.11.4.dist-info → astrbot-4.12.0.dist-info}/entry_points.txt +0 -0
  40. {astrbot-4.11.4.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,5 @@
1
+ from .filesystem import FileSystemComponent
2
+ from .python import PythonComponent
3
+ from .shell import ShellComponent
4
+
5
+ __all__ = ["PythonComponent", "ShellComponent", "FileSystemComponent"]
@@ -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,10 @@
1
+ from .fs import FileDownloadTool, FileUploadTool
2
+ from .python import PythonTool
3
+ from .shell import ExecuteShellTool
4
+
5
+ __all__ = [
6
+ "FileUploadTool",
7
+ "PythonTool",
8
+ "ExecuteShellTool",
9
+ "FileDownloadTool",
10
+ ]
@@ -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)}"