AstrBot 4.12.4__py3-none-any.whl → 4.13.1__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/builtin_stars/astrbot/process_llm_request.py +42 -1
- astrbot/cli/__init__.py +1 -1
- astrbot/core/agent/runners/tool_loop_agent_runner.py +91 -1
- astrbot/core/agent/tool.py +61 -20
- astrbot/core/astr_agent_tool_exec.py +2 -2
- astrbot/core/{sandbox → computer}/booters/base.py +4 -4
- astrbot/core/{sandbox → computer}/booters/boxlite.py +2 -2
- astrbot/core/computer/booters/local.py +234 -0
- astrbot/core/{sandbox → computer}/booters/shipyard.py +2 -2
- astrbot/core/computer/computer_client.py +102 -0
- astrbot/core/{sandbox → computer}/tools/__init__.py +2 -1
- astrbot/core/{sandbox → computer}/tools/fs.py +1 -1
- astrbot/core/computer/tools/python.py +94 -0
- astrbot/core/{sandbox → computer}/tools/shell.py +13 -5
- astrbot/core/config/default.py +61 -9
- astrbot/core/db/__init__.py +3 -0
- astrbot/core/db/po.py +23 -61
- astrbot/core/db/sqlite.py +19 -1
- astrbot/core/message/components.py +2 -2
- astrbot/core/persona_mgr.py +8 -0
- astrbot/core/pipeline/context_utils.py +2 -2
- astrbot/core/pipeline/preprocess_stage/stage.py +1 -1
- astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +21 -6
- astrbot/core/pipeline/process_stage/utils.py +19 -4
- astrbot/core/pipeline/scheduler.py +1 -1
- astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +3 -3
- astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +5 -7
- astrbot/core/provider/manager.py +31 -0
- astrbot/core/provider/sources/gemini_source.py +12 -9
- astrbot/core/skills/__init__.py +3 -0
- astrbot/core/skills/skill_manager.py +238 -0
- astrbot/core/star/command_management.py +1 -1
- astrbot/core/star/config.py +1 -1
- astrbot/core/star/filter/command.py +1 -1
- astrbot/core/star/filter/custom_filter.py +2 -2
- astrbot/core/star/register/star_handler.py +1 -1
- astrbot/core/utils/astrbot_path.py +6 -0
- astrbot/dashboard/routes/__init__.py +2 -0
- astrbot/dashboard/routes/config.py +236 -2
- astrbot/dashboard/routes/persona.py +7 -0
- astrbot/dashboard/routes/skills.py +148 -0
- astrbot/dashboard/routes/util.py +102 -0
- astrbot/dashboard/server.py +19 -5
- {astrbot-4.12.4.dist-info → astrbot-4.13.1.dist-info}/METADATA +2 -2
- {astrbot-4.12.4.dist-info → astrbot-4.13.1.dist-info}/RECORD +52 -47
- astrbot/core/sandbox/sandbox_client.py +0 -52
- astrbot/core/sandbox/tools/python.py +0 -74
- /astrbot/core/{sandbox → computer}/olayer/__init__.py +0 -0
- /astrbot/core/{sandbox → computer}/olayer/filesystem.py +0 -0
- /astrbot/core/{sandbox → computer}/olayer/python.py +0 -0
- /astrbot/core/{sandbox → computer}/olayer/shell.py +0 -0
- {astrbot-4.12.4.dist-info → astrbot-4.13.1.dist-info}/WHEEL +0 -0
- {astrbot-4.12.4.dist-info → astrbot-4.13.1.dist-info}/entry_points.txt +0 -0
- {astrbot-4.12.4.dist-info → astrbot-4.13.1.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
|
|
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.
|
|
1
|
+
__version__ = "4.13.1"
|
|
@@ -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.
|
|
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)
|
astrbot/core/agent/tool.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
176
|
-
"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
|
-
|
|
250
|
-
"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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
26
|
+
"""Download file from the computer."""
|
|
27
27
|
...
|
|
28
28
|
|
|
29
29
|
async def available(self) -> bool:
|
|
30
|
-
"""Check if the
|
|
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
|
|
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(
|
|
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
|
|
6
|
+
from .base import ComputerBooter
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
class ShipyardBooter(
|
|
9
|
+
class ShipyardBooter(ComputerBooter):
|
|
10
10
|
def __init__(
|
|
11
11
|
self,
|
|
12
12
|
endpoint_url: str,
|