AstrBot 4.12.4__py3-none-any.whl → 4.13.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 (54) hide show
  1. astrbot/builtin_stars/astrbot/process_llm_request.py +42 -1
  2. astrbot/cli/__init__.py +1 -1
  3. astrbot/core/agent/runners/tool_loop_agent_runner.py +91 -1
  4. astrbot/core/agent/tool.py +61 -20
  5. astrbot/core/astr_agent_tool_exec.py +2 -2
  6. astrbot/core/{sandbox → computer}/booters/base.py +4 -4
  7. astrbot/core/{sandbox → computer}/booters/boxlite.py +2 -2
  8. astrbot/core/computer/booters/local.py +234 -0
  9. astrbot/core/{sandbox → computer}/booters/shipyard.py +2 -2
  10. astrbot/core/computer/computer_client.py +102 -0
  11. astrbot/core/{sandbox → computer}/tools/__init__.py +2 -1
  12. astrbot/core/{sandbox → computer}/tools/fs.py +1 -1
  13. astrbot/core/computer/tools/python.py +94 -0
  14. astrbot/core/{sandbox → computer}/tools/shell.py +13 -5
  15. astrbot/core/config/default.py +61 -9
  16. astrbot/core/db/__init__.py +3 -0
  17. astrbot/core/db/po.py +4 -0
  18. astrbot/core/db/sqlite.py +19 -1
  19. astrbot/core/message/components.py +2 -2
  20. astrbot/core/persona_mgr.py +8 -0
  21. astrbot/core/pipeline/context_utils.py +2 -2
  22. astrbot/core/pipeline/preprocess_stage/stage.py +1 -1
  23. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +16 -2
  24. astrbot/core/pipeline/process_stage/utils.py +19 -4
  25. astrbot/core/pipeline/scheduler.py +1 -1
  26. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +3 -3
  27. astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +5 -7
  28. astrbot/core/provider/manager.py +31 -0
  29. astrbot/core/provider/sources/gemini_source.py +12 -9
  30. astrbot/core/skills/__init__.py +3 -0
  31. astrbot/core/skills/skill_manager.py +237 -0
  32. astrbot/core/star/command_management.py +1 -1
  33. astrbot/core/star/config.py +1 -1
  34. astrbot/core/star/filter/command.py +1 -1
  35. astrbot/core/star/filter/custom_filter.py +2 -2
  36. astrbot/core/star/register/star_handler.py +1 -1
  37. astrbot/core/utils/astrbot_path.py +6 -0
  38. astrbot/dashboard/routes/__init__.py +2 -0
  39. astrbot/dashboard/routes/config.py +236 -2
  40. astrbot/dashboard/routes/persona.py +7 -0
  41. astrbot/dashboard/routes/skills.py +148 -0
  42. astrbot/dashboard/routes/util.py +102 -0
  43. astrbot/dashboard/server.py +19 -5
  44. {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/METADATA +1 -1
  45. {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/RECORD +52 -47
  46. astrbot/core/sandbox/sandbox_client.py +0 -52
  47. astrbot/core/sandbox/tools/python.py +0 -74
  48. /astrbot/core/{sandbox → computer}/olayer/__init__.py +0 -0
  49. /astrbot/core/{sandbox → computer}/olayer/filesystem.py +0 -0
  50. /astrbot/core/{sandbox → computer}/olayer/python.py +0 -0
  51. /astrbot/core/{sandbox → computer}/olayer/shell.py +0 -0
  52. {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/WHEEL +0 -0
  53. {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/entry_points.txt +0 -0
  54. {astrbot-4.12.4.dist-info → astrbot-4.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -10,8 +10,11 @@ from astrbot.api.provider import Provider, ProviderRequest
10
10
  from astrbot.core.agent.message import TextPart
11
11
  from astrbot.core.pipeline.process_stage.utils import (
12
12
  CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT,
13
+ LOCAL_EXECUTE_SHELL_TOOL,
14
+ LOCAL_PYTHON_TOOL,
13
15
  )
14
16
  from astrbot.core.provider.func_tool_manager import ToolSet
17
+ from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
15
18
 
16
19
 
17
20
  class ProcessLLMRequest:
@@ -25,6 +28,15 @@ class ProcessLLMRequest:
25
28
  else:
26
29
  logger.info(f"Timezone set to: {self.timezone}")
27
30
 
31
+ self.skill_manager = SkillManager()
32
+
33
+ def _apply_local_env_tools(self, req: ProviderRequest) -> None:
34
+ """Add local environment tools to the provider request."""
35
+ if req.func_tool is None:
36
+ req.func_tool = ToolSet()
37
+ req.func_tool.add_tool(LOCAL_EXECUTE_SHELL_TOOL)
38
+ req.func_tool.add_tool(LOCAL_PYTHON_TOOL)
39
+
28
40
  async def _ensure_persona(
29
41
  self, req: ProviderRequest, cfg: dict, umo: str, platform_type: str
30
42
  ):
@@ -66,6 +78,30 @@ class ProcessLLMRequest:
66
78
  if begin_dialogs := copy.deepcopy(persona["_begin_dialogs_processed"]):
67
79
  req.contexts[:0] = begin_dialogs
68
80
 
81
+ # skills select and prompt
82
+ runtime = self.skills_cfg.get("runtime", "local")
83
+ skills = self.skill_manager.list_skills(active_only=True, runtime=runtime)
84
+ if runtime == "sandbox" and not self.sandbox_cfg.get("enable", False):
85
+ logger.warning(
86
+ "Skills runtime is set to sandbox, but sandbox mode is disabled, will skip skills prompt injection.",
87
+ )
88
+ req.system_prompt += "\n[Background: User added some skills, and skills runtime is set to sandbox, but sandbox mode is disabled. So skills will be unavailable.]\n"
89
+ elif skills:
90
+ # persona.skills == None means all skills are allowed
91
+ if persona and persona.get("skills") is not None:
92
+ if not persona["skills"]:
93
+ return
94
+ allowed = set(persona["skills"])
95
+ skills = [skill for skill in skills if skill.name in allowed]
96
+ if skills:
97
+ req.system_prompt += f"\n{build_skills_prompt(skills)}\n"
98
+
99
+ # if user wants to use skills in non-sandbox mode, apply local env tools
100
+ runtime = self.skills_cfg.get("runtime", "local")
101
+ sandbox_enabled = self.sandbox_cfg.get("enable", False)
102
+ if runtime == "local" and not sandbox_enabled:
103
+ self._apply_local_env_tools(req)
104
+
69
105
  # tools select
70
106
  tmgr = self.ctx.get_llm_tool_manager()
71
107
  if (persona and persona.get("tools") is None) or not persona:
@@ -81,7 +117,10 @@ class ProcessLLMRequest:
81
117
  tool = tmgr.get_func(tool_name)
82
118
  if tool and tool.active:
83
119
  toolset.add_tool(tool)
84
- req.func_tool = toolset
120
+ if not req.func_tool:
121
+ req.func_tool = toolset
122
+ else:
123
+ req.func_tool.merge(toolset)
85
124
  logger.debug(f"Tool set for persona {persona_id}: {toolset.names()}")
86
125
 
87
126
  async def _ensure_img_caption(
@@ -134,6 +173,8 @@ class ProcessLLMRequest:
134
173
  cfg: dict = self.ctx.get_config(umo=event.unified_msg_origin)[
135
174
  "provider_settings"
136
175
  ]
176
+ self.skills_cfg = cfg.get("skills", {})
177
+ self.sandbox_cfg = cfg.get("sandbox", {})
137
178
 
138
179
  # prompt prefix
139
180
  if prefix := cfg.get("prompt_prefix"):
astrbot/cli/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "4.12.4"
1
+ __version__ = "4.13.0"
@@ -1,3 +1,4 @@
1
+ import copy
1
2
  import sys
2
3
  import time
3
4
  import traceback
@@ -14,6 +15,7 @@ from mcp.types import (
14
15
 
15
16
  from astrbot import logger
16
17
  from astrbot.core.agent.message import TextPart, ThinkPart
18
+ from astrbot.core.agent.tool import ToolSet
17
19
  from astrbot.core.message.components import Json
18
20
  from astrbot.core.message.message_event_result import (
19
21
  MessageChain,
@@ -64,6 +66,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
64
66
  # customize
65
67
  custom_token_counter: TokenCounter | None = None,
66
68
  custom_compressor: ContextCompressor | None = None,
69
+ tool_schema_mode: str | None = "full",
67
70
  **kwargs: T.Any,
68
71
  ) -> None:
69
72
  self.req = request
@@ -99,6 +102,24 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
99
102
  self.agent_hooks = agent_hooks
100
103
  self.run_context = run_context
101
104
 
105
+ # These two are used for tool schema mode handling
106
+ # We now have two modes:
107
+ # - "full": use full tool schema for LLM calls, default.
108
+ # - "skills_like": use light tool schema for LLM calls, and re-query with param-only schema when needed.
109
+ # Light tool schema does not include tool parameters.
110
+ # This can reduce token usage when tools have large descriptions.
111
+ # See #4681
112
+ self.tool_schema_mode = tool_schema_mode
113
+ self._tool_schema_param_set = None
114
+ if tool_schema_mode == "skills_like":
115
+ tool_set = self.req.func_tool
116
+ if not tool_set:
117
+ return
118
+ light_set = tool_set.get_light_tool_set()
119
+ self._tool_schema_param_set = tool_set.get_param_only_tool_set()
120
+ # MODIFIE the req.func_tool to use light tool schemas
121
+ self.req.func_tool = light_set
122
+
102
123
  messages = []
103
124
  # append existing messages in the run context
104
125
  for msg in request.contexts:
@@ -253,6 +274,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
253
274
 
254
275
  # 如果有工具调用,还需处理工具调用
255
276
  if llm_resp.tools_call_name:
277
+ if self.tool_schema_mode == "skills_like":
278
+ llm_resp, _ = await self._resolve_tool_exec(llm_resp)
279
+
256
280
  tool_call_result_blocks = []
257
281
  async for result in self._handle_function_tools(self.req, llm_resp):
258
282
  if isinstance(result, list):
@@ -269,6 +293,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
269
293
  type=ar_type,
270
294
  data=AgentResponseData(chain=result),
271
295
  )
296
+
272
297
  # 将结果添加到上下文中
273
298
  parts = []
274
299
  if llm_resp.reasoning_content or llm_resp.reasoning_signature:
@@ -354,7 +379,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
354
379
  try:
355
380
  if not req.func_tool:
356
381
  return
357
- func_tool = req.func_tool.get_func(func_tool_name)
382
+ func_tool = req.func_tool.get_tool(func_tool_name)
358
383
  logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}")
359
384
 
360
385
  if not func_tool:
@@ -537,6 +562,71 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
537
562
  if tool_call_result_blocks:
538
563
  yield tool_call_result_blocks
539
564
 
565
+ def _build_tool_requery_context(
566
+ self, tool_names: list[str]
567
+ ) -> list[dict[str, T.Any]]:
568
+ """Build contexts for re-querying LLM with param-only tool schemas."""
569
+ contexts: list[dict[str, T.Any]] = []
570
+ for msg in self.run_context.messages:
571
+ if hasattr(msg, "model_dump"):
572
+ contexts.append(msg.model_dump()) # type: ignore[call-arg]
573
+ elif isinstance(msg, dict):
574
+ contexts.append(copy.deepcopy(msg))
575
+ instruction = (
576
+ "You have decided to call tool(s): "
577
+ + ", ".join(tool_names)
578
+ + ". Now call the tool(s) with required arguments using the tool schema, "
579
+ "and follow the existing tool-use rules."
580
+ )
581
+ if contexts and contexts[0].get("role") == "system":
582
+ content = contexts[0].get("content") or ""
583
+ contexts[0]["content"] = f"{content}\n{instruction}"
584
+ else:
585
+ contexts.insert(0, {"role": "system", "content": instruction})
586
+ return contexts
587
+
588
+ def _build_tool_subset(self, tool_set: ToolSet, tool_names: list[str]) -> ToolSet:
589
+ """Build a subset of tools from the given tool set based on tool names."""
590
+ subset = ToolSet()
591
+ for name in tool_names:
592
+ tool = tool_set.get_tool(name)
593
+ if tool:
594
+ subset.add_tool(tool)
595
+ return subset
596
+
597
+ async def _resolve_tool_exec(
598
+ self,
599
+ llm_resp: LLMResponse,
600
+ ) -> tuple[LLMResponse, ToolSet | None]:
601
+ """Used in 'skills_like' tool schema mode to re-query LLM with param-only tool schemas."""
602
+ tool_names = llm_resp.tools_call_name
603
+ if not tool_names:
604
+ return llm_resp, self.req.func_tool
605
+ full_tool_set = self.req.func_tool
606
+ if not isinstance(full_tool_set, ToolSet):
607
+ return llm_resp, self.req.func_tool
608
+
609
+ subset = self._build_tool_subset(full_tool_set, tool_names)
610
+ if not subset.tools:
611
+ return llm_resp, full_tool_set
612
+
613
+ if isinstance(self._tool_schema_param_set, ToolSet):
614
+ param_subset = self._build_tool_subset(
615
+ self._tool_schema_param_set, tool_names
616
+ )
617
+ if param_subset.tools and tool_names:
618
+ contexts = self._build_tool_requery_context(tool_names)
619
+ requery_resp = await self.provider.text_chat(
620
+ contexts=contexts,
621
+ func_tool=param_subset,
622
+ model=self.req.model,
623
+ session_id=self.req.session_id,
624
+ )
625
+ if requery_resp:
626
+ llm_resp = requery_resp
627
+
628
+ return llm_resp, subset
629
+
540
630
  def done(self) -> bool:
541
631
  """检查 Agent 是否已完成工作"""
542
632
  return self._state in (AgentState.DONE, AgentState.ERROR)
@@ -1,3 +1,4 @@
1
+ import copy
1
2
  from collections.abc import AsyncGenerator, Awaitable, Callable
2
3
  from typing import Any, Generic
3
4
 
@@ -102,6 +103,47 @@ class ToolSet:
102
103
  return tool
103
104
  return None
104
105
 
106
+ def get_light_tool_set(self) -> "ToolSet":
107
+ """Return a light tool set with only name/description."""
108
+ light_tools = []
109
+ for tool in self.tools:
110
+ if hasattr(tool, "active") and not tool.active:
111
+ continue
112
+ light_params = {
113
+ "type": "object",
114
+ "properties": {},
115
+ }
116
+ light_tools.append(
117
+ FunctionTool(
118
+ name=tool.name,
119
+ parameters=light_params,
120
+ description=tool.description,
121
+ handler=None,
122
+ )
123
+ )
124
+ return ToolSet(light_tools)
125
+
126
+ def get_param_only_tool_set(self) -> "ToolSet":
127
+ """Return a tool set with name/parameters only (no description)."""
128
+ param_tools = []
129
+ for tool in self.tools:
130
+ if hasattr(tool, "active") and not tool.active:
131
+ continue
132
+ params = (
133
+ copy.deepcopy(tool.parameters)
134
+ if tool.parameters
135
+ else {"type": "object", "properties": {}}
136
+ )
137
+ param_tools.append(
138
+ FunctionTool(
139
+ name=tool.name,
140
+ parameters=params,
141
+ description="",
142
+ handler=None,
143
+ )
144
+ )
145
+ return ToolSet(param_tools)
146
+
105
147
  @deprecated(reason="Use add_tool() instead", version="4.0.0")
106
148
  def add_func(
107
149
  self,
@@ -147,18 +189,15 @@ class ToolSet:
147
189
  """Convert tools to OpenAI API function calling schema format."""
148
190
  result = []
149
191
  for tool in self.tools:
150
- func_def = {
151
- "type": "function",
152
- "function": {
153
- "name": tool.name,
154
- "description": tool.description,
155
- },
156
- }
192
+ func_def = {"type": "function", "function": {"name": tool.name}}
193
+ if tool.description:
194
+ func_def["function"]["description"] = tool.description
157
195
 
158
- if (
159
- tool.parameters and tool.parameters.get("properties")
160
- ) or not omit_empty_parameter_field:
161
- func_def["function"]["parameters"] = tool.parameters
196
+ if tool.parameters is not None:
197
+ if (
198
+ tool.parameters and tool.parameters.get("properties")
199
+ ) or not omit_empty_parameter_field:
200
+ func_def["function"]["parameters"] = tool.parameters
162
201
 
163
202
  result.append(func_def)
164
203
  return result
@@ -171,11 +210,9 @@ class ToolSet:
171
210
  if tool.parameters:
172
211
  input_schema["properties"] = tool.parameters.get("properties", {})
173
212
  input_schema["required"] = tool.parameters.get("required", [])
174
- tool_def = {
175
- "name": tool.name,
176
- "description": tool.description,
177
- "input_schema": input_schema,
178
- }
213
+ tool_def = {"name": tool.name, "input_schema": input_schema}
214
+ if tool.description:
215
+ tool_def["description"] = tool.description
179
216
  result.append(tool_def)
180
217
  return result
181
218
 
@@ -245,10 +282,9 @@ class ToolSet:
245
282
 
246
283
  tools = []
247
284
  for tool in self.tools:
248
- d: dict[str, Any] = {
249
- "name": tool.name,
250
- "description": tool.description,
251
- }
285
+ d: dict[str, Any] = {"name": tool.name}
286
+ if tool.description:
287
+ d["description"] = tool.description
252
288
  if tool.parameters:
253
289
  d["parameters"] = convert_schema(tool.parameters)
254
290
  tools.append(d)
@@ -274,6 +310,11 @@ class ToolSet:
274
310
  """获取所有工具的名称列表"""
275
311
  return [tool.name for tool in self.tools]
276
312
 
313
+ def merge(self, other: "ToolSet"):
314
+ """Merge another ToolSet into this one."""
315
+ for tool in other.tools:
316
+ self.add_tool(tool)
317
+
277
318
  def __len__(self):
278
319
  return len(self.tools)
279
320
 
@@ -256,7 +256,7 @@ async def call_local_llm_tool(
256
256
  # 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码
257
257
  # 返回值只能是 MessageEventResult 或者 None(无返回值)
258
258
  _has_yielded = True
259
- if isinstance(ret, (MessageEventResult, CommandResult)):
259
+ if isinstance(ret, MessageEventResult | CommandResult):
260
260
  # 如果返回值是 MessageEventResult, 设置结果并继续
261
261
  event.set_result(ret)
262
262
  yield
@@ -273,7 +273,7 @@ async def call_local_llm_tool(
273
273
  elif inspect.iscoroutine(ready_to_call):
274
274
  # 如果只是一个协程, 直接执行
275
275
  ret = await ready_to_call
276
- if isinstance(ret, (MessageEventResult, CommandResult)):
276
+ if isinstance(ret, MessageEventResult | CommandResult):
277
277
  event.set_result(ret)
278
278
  yield
279
279
  else:
@@ -1,7 +1,7 @@
1
1
  from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
2
2
 
3
3
 
4
- class SandboxBooter:
4
+ class ComputerBooter:
5
5
  @property
6
6
  def fs(self) -> FileSystemComponent: ...
7
7
 
@@ -16,16 +16,16 @@ class SandboxBooter:
16
16
  async def shutdown(self) -> None: ...
17
17
 
18
18
  async def upload_file(self, path: str, file_name: str) -> dict:
19
- """Upload file to sandbox.
19
+ """Upload file to the computer.
20
20
 
21
21
  Should return a dict with `success` (bool) and `file_path` (str) keys.
22
22
  """
23
23
  ...
24
24
 
25
25
  async def download_file(self, remote_path: str, local_path: str):
26
- """Download file from sandbox."""
26
+ """Download file from the computer."""
27
27
  ...
28
28
 
29
29
  async def available(self) -> bool:
30
- """Check if the sandbox is available."""
30
+ """Check if the computer is available."""
31
31
  ...
@@ -11,7 +11,7 @@ from shipyard.shell import ShellComponent as ShipyardShellComponent
11
11
  from astrbot.api import logger
12
12
 
13
13
  from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
14
- from .base import SandboxBooter
14
+ from .base import ComputerBooter
15
15
 
16
16
 
17
17
  class MockShipyardSandboxClient:
@@ -124,7 +124,7 @@ class MockShipyardSandboxClient:
124
124
  loop -= 1
125
125
 
126
126
 
127
- class BoxliteBooter(SandboxBooter):
127
+ class BoxliteBooter(ComputerBooter):
128
128
  async def boot(self, session_id: str) -> None:
129
129
  logger.info(
130
130
  f"Booting(Boxlite) for session: {session_id}, this may take a while..."
@@ -0,0 +1,234 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from astrbot.api import logger
12
+ from astrbot.core.utils.astrbot_path import (
13
+ get_astrbot_data_path,
14
+ get_astrbot_root,
15
+ get_astrbot_temp_path,
16
+ )
17
+
18
+ from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
19
+ from .base import ComputerBooter
20
+
21
+ _BLOCKED_COMMAND_PATTERNS = [
22
+ " rm -rf ",
23
+ " rm -fr ",
24
+ " rm -r ",
25
+ " mkfs",
26
+ " dd if=",
27
+ " shutdown",
28
+ " reboot",
29
+ " poweroff",
30
+ " halt",
31
+ " sudo ",
32
+ ":(){:|:&};:",
33
+ " kill -9 ",
34
+ " killall ",
35
+ ]
36
+
37
+
38
+ def _is_safe_command(command: str) -> bool:
39
+ cmd = f" {command.strip().lower()} "
40
+ return not any(pat in cmd for pat in _BLOCKED_COMMAND_PATTERNS)
41
+
42
+
43
+ def _ensure_safe_path(path: str) -> str:
44
+ abs_path = os.path.abspath(path)
45
+ allowed_roots = [
46
+ os.path.abspath(get_astrbot_root()),
47
+ os.path.abspath(get_astrbot_data_path()),
48
+ os.path.abspath(get_astrbot_temp_path()),
49
+ ]
50
+ if not any(abs_path.startswith(root) for root in allowed_roots):
51
+ raise PermissionError("Path is outside the allowed computer roots.")
52
+ return abs_path
53
+
54
+
55
+ @dataclass
56
+ class LocalShellComponent(ShellComponent):
57
+ async def exec(
58
+ self,
59
+ command: str,
60
+ cwd: str | None = None,
61
+ env: dict[str, str] | None = None,
62
+ timeout: int | None = 30,
63
+ shell: bool = True,
64
+ background: bool = False,
65
+ ) -> dict[str, Any]:
66
+ if not _is_safe_command(command):
67
+ raise PermissionError("Blocked unsafe shell command.")
68
+
69
+ def _run() -> dict[str, Any]:
70
+ run_env = os.environ.copy()
71
+ if env:
72
+ run_env.update({str(k): str(v) for k, v in env.items()})
73
+ working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
74
+ if background:
75
+ proc = subprocess.Popen(
76
+ command,
77
+ shell=shell,
78
+ cwd=working_dir,
79
+ env=run_env,
80
+ stdout=subprocess.PIPE,
81
+ stderr=subprocess.PIPE,
82
+ text=True,
83
+ )
84
+ return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None}
85
+ result = subprocess.run(
86
+ command,
87
+ shell=shell,
88
+ cwd=working_dir,
89
+ env=run_env,
90
+ timeout=timeout,
91
+ capture_output=True,
92
+ text=True,
93
+ )
94
+ return {
95
+ "stdout": result.stdout,
96
+ "stderr": result.stderr,
97
+ "exit_code": result.returncode,
98
+ }
99
+
100
+ return await asyncio.to_thread(_run)
101
+
102
+
103
+ @dataclass
104
+ class LocalPythonComponent(PythonComponent):
105
+ async def exec(
106
+ self,
107
+ code: str,
108
+ kernel_id: str | None = None,
109
+ timeout: int = 30,
110
+ silent: bool = False,
111
+ ) -> dict[str, Any]:
112
+ def _run() -> dict[str, Any]:
113
+ try:
114
+ result = subprocess.run(
115
+ [os.environ.get("PYTHON", sys.executable), "-c", code],
116
+ timeout=timeout,
117
+ capture_output=True,
118
+ text=True,
119
+ )
120
+ stdout = "" if silent else result.stdout
121
+ stderr = result.stderr if result.returncode != 0 else ""
122
+ return {
123
+ "data": {
124
+ "output": {"text": stdout, "images": []},
125
+ "error": stderr,
126
+ }
127
+ }
128
+ except subprocess.TimeoutExpired:
129
+ return {
130
+ "data": {
131
+ "output": {"text": "", "images": []},
132
+ "error": "Execution timed out.",
133
+ }
134
+ }
135
+
136
+ return await asyncio.to_thread(_run)
137
+
138
+
139
+ @dataclass
140
+ class LocalFileSystemComponent(FileSystemComponent):
141
+ async def create_file(
142
+ self, path: str, content: str = "", mode: int = 0o644
143
+ ) -> dict[str, Any]:
144
+ def _run() -> dict[str, Any]:
145
+ abs_path = _ensure_safe_path(path)
146
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
147
+ with open(abs_path, "w", encoding="utf-8") as f:
148
+ f.write(content)
149
+ os.chmod(abs_path, mode)
150
+ return {"success": True, "path": abs_path}
151
+
152
+ return await asyncio.to_thread(_run)
153
+
154
+ async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
155
+ def _run() -> dict[str, Any]:
156
+ abs_path = _ensure_safe_path(path)
157
+ with open(abs_path, encoding=encoding) as f:
158
+ content = f.read()
159
+ return {"success": True, "content": content}
160
+
161
+ return await asyncio.to_thread(_run)
162
+
163
+ async def write_file(
164
+ self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
165
+ ) -> dict[str, Any]:
166
+ def _run() -> dict[str, Any]:
167
+ abs_path = _ensure_safe_path(path)
168
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
169
+ with open(abs_path, mode, encoding=encoding) as f:
170
+ f.write(content)
171
+ return {"success": True, "path": abs_path}
172
+
173
+ return await asyncio.to_thread(_run)
174
+
175
+ async def delete_file(self, path: str) -> dict[str, Any]:
176
+ def _run() -> dict[str, Any]:
177
+ abs_path = _ensure_safe_path(path)
178
+ if os.path.isdir(abs_path):
179
+ shutil.rmtree(abs_path)
180
+ else:
181
+ os.remove(abs_path)
182
+ return {"success": True, "path": abs_path}
183
+
184
+ return await asyncio.to_thread(_run)
185
+
186
+ async def list_dir(
187
+ self, path: str = ".", show_hidden: bool = False
188
+ ) -> dict[str, Any]:
189
+ def _run() -> dict[str, Any]:
190
+ abs_path = _ensure_safe_path(path)
191
+ entries = os.listdir(abs_path)
192
+ if not show_hidden:
193
+ entries = [e for e in entries if not e.startswith(".")]
194
+ return {"success": True, "entries": entries}
195
+
196
+ return await asyncio.to_thread(_run)
197
+
198
+
199
+ class LocalBooter(ComputerBooter):
200
+ def __init__(self) -> None:
201
+ self._fs = LocalFileSystemComponent()
202
+ self._python = LocalPythonComponent()
203
+ self._shell = LocalShellComponent()
204
+
205
+ async def boot(self, session_id: str) -> None:
206
+ logger.info(f"Local computer booter initialized for session: {session_id}")
207
+
208
+ async def shutdown(self) -> None:
209
+ logger.info("Local computer booter shutdown complete.")
210
+
211
+ @property
212
+ def fs(self) -> FileSystemComponent:
213
+ return self._fs
214
+
215
+ @property
216
+ def python(self) -> PythonComponent:
217
+ return self._python
218
+
219
+ @property
220
+ def shell(self) -> ShellComponent:
221
+ return self._shell
222
+
223
+ async def upload_file(self, path: str, file_name: str) -> dict:
224
+ raise NotImplementedError(
225
+ "LocalBooter does not support upload_file operation. Use shell instead."
226
+ )
227
+
228
+ async def download_file(self, remote_path: str, local_path: str):
229
+ raise NotImplementedError(
230
+ "LocalBooter does not support download_file operation. Use shell instead."
231
+ )
232
+
233
+ async def available(self) -> bool:
234
+ return True
@@ -3,10 +3,10 @@ from shipyard import ShipyardClient, Spec
3
3
  from astrbot.api import logger
4
4
 
5
5
  from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
6
- from .base import SandboxBooter
6
+ from .base import ComputerBooter
7
7
 
8
8
 
9
- class ShipyardBooter(SandboxBooter):
9
+ class ShipyardBooter(ComputerBooter):
10
10
  def __init__(
11
11
  self,
12
12
  endpoint_url: str,