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.
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 +23 -61
  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 +21 -6
  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 +238 -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.1.dist-info}/METADATA +2 -2
  45. {astrbot-4.12.4.dist-info → astrbot-4.13.1.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.1.dist-info}/WHEEL +0 -0
  53. {astrbot-4.12.4.dist-info → astrbot-4.13.1.dist-info}/entry_points.txt +0 -0
  54. {astrbot-4.12.4.dist-info → astrbot-4.13.1.dist-info}/licenses/LICENSE +0 -0
astrbot/core/db/po.py CHANGED
@@ -6,6 +6,14 @@ from typing import TypedDict
6
6
  from sqlmodel import JSON, Field, SQLModel, Text, UniqueConstraint
7
7
 
8
8
 
9
+ class TimestampMixin(SQLModel):
10
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
11
+ updated_at: datetime = Field(
12
+ default_factory=lambda: datetime.now(timezone.utc),
13
+ sa_column_kwargs={"onupdate": lambda: datetime.now(timezone.utc)},
14
+ )
15
+
16
+
9
17
  class PlatformStat(SQLModel, table=True):
10
18
  """This class represents the statistics of bot usage across different platforms.
11
19
 
@@ -30,7 +38,7 @@ class PlatformStat(SQLModel, table=True):
30
38
  )
31
39
 
32
40
 
33
- class ConversationV2(SQLModel, table=True):
41
+ class ConversationV2(TimestampMixin, SQLModel, table=True):
34
42
  __tablename__: str = "conversations"
35
43
 
36
44
  inner_conversation_id: int | None = Field(
@@ -47,11 +55,7 @@ class ConversationV2(SQLModel, table=True):
47
55
  platform_id: str = Field(nullable=False)
48
56
  user_id: str = Field(nullable=False)
49
57
  content: list | None = Field(default=None, sa_type=JSON)
50
- created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
51
- updated_at: datetime = Field(
52
- default_factory=lambda: datetime.now(timezone.utc),
53
- sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
54
- )
58
+
55
59
  title: str | None = Field(default=None, max_length=255)
56
60
  persona_id: str | None = Field(default=None)
57
61
  token_usage: int = Field(default=0, nullable=False)
@@ -68,7 +72,7 @@ class ConversationV2(SQLModel, table=True):
68
72
  )
69
73
 
70
74
 
71
- class PersonaFolder(SQLModel, table=True):
75
+ class PersonaFolder(TimestampMixin, SQLModel, table=True):
72
76
  """Persona 文件夹,支持递归层级结构。
73
77
 
74
78
  用于组织和管理多个 Persona,类似于文件系统的目录结构。
@@ -92,11 +96,6 @@ class PersonaFolder(SQLModel, table=True):
92
96
  """父文件夹ID,NULL表示根目录"""
93
97
  description: str | None = Field(default=None, sa_type=Text)
94
98
  sort_order: int = Field(default=0)
95
- created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
96
- updated_at: datetime = Field(
97
- default_factory=lambda: datetime.now(timezone.utc),
98
- sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
99
- )
100
99
 
101
100
  __table_args__ = (
102
101
  UniqueConstraint(
@@ -106,7 +105,7 @@ class PersonaFolder(SQLModel, table=True):
106
105
  )
107
106
 
108
107
 
109
- class Persona(SQLModel, table=True):
108
+ class Persona(TimestampMixin, SQLModel, table=True):
110
109
  """Persona is a set of instructions for LLMs to follow.
111
110
 
112
111
  It can be used to customize the behavior of LLMs.
@@ -125,15 +124,12 @@ class Persona(SQLModel, table=True):
125
124
  """a list of strings, each representing a dialog to start with"""
126
125
  tools: list | None = Field(default=None, sa_type=JSON)
127
126
  """None means use ALL tools for default, empty list means no tools, otherwise a list of tool names."""
127
+ skills: list | None = Field(default=None, sa_type=JSON)
128
+ """None means use ALL skills for default, empty list means no skills, otherwise a list of skill names."""
128
129
  folder_id: str | None = Field(default=None, max_length=36)
129
130
  """所属文件夹ID,NULL 表示在根目录"""
130
131
  sort_order: int = Field(default=0)
131
132
  """排序顺序"""
132
- created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
133
- updated_at: datetime = Field(
134
- default_factory=lambda: datetime.now(timezone.utc),
135
- sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
136
- )
137
133
 
138
134
  __table_args__ = (
139
135
  UniqueConstraint(
@@ -143,7 +139,7 @@ class Persona(SQLModel, table=True):
143
139
  )
144
140
 
145
141
 
146
- class Preference(SQLModel, table=True):
142
+ class Preference(TimestampMixin, SQLModel, table=True):
147
143
  """This class represents preferences for bots."""
148
144
 
149
145
  __tablename__: str = "preferences"
@@ -159,11 +155,6 @@ class Preference(SQLModel, table=True):
159
155
  """ID of the scope, such as 'global', 'umo', 'plugin_name'."""
160
156
  key: str = Field(nullable=False)
161
157
  value: dict = Field(sa_type=JSON, nullable=False)
162
- created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
163
- updated_at: datetime = Field(
164
- default_factory=lambda: datetime.now(timezone.utc),
165
- sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
166
- )
167
158
 
168
159
  __table_args__ = (
169
160
  UniqueConstraint(
@@ -175,7 +166,7 @@ class Preference(SQLModel, table=True):
175
166
  )
176
167
 
177
168
 
178
- class PlatformMessageHistory(SQLModel, table=True):
169
+ class PlatformMessageHistory(TimestampMixin, SQLModel, table=True):
179
170
  """This class represents the message history for a specific platform.
180
171
 
181
172
  It is used to store messages that are not LLM-generated, such as user messages
@@ -196,14 +187,9 @@ class PlatformMessageHistory(SQLModel, table=True):
196
187
  default=None,
197
188
  ) # Name of the sender in the platform
198
189
  content: dict = Field(sa_type=JSON, nullable=False) # a message chain list
199
- created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
200
- updated_at: datetime = Field(
201
- default_factory=lambda: datetime.now(timezone.utc),
202
- sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
203
- )
204
190
 
205
191
 
206
- class PlatformSession(SQLModel, table=True):
192
+ class PlatformSession(TimestampMixin, SQLModel, table=True):
207
193
  """Platform session table for managing user sessions across different platforms.
208
194
 
209
195
  A session represents a chat window for a specific user on a specific platform.
@@ -231,11 +217,6 @@ class PlatformSession(SQLModel, table=True):
231
217
  """Display name for the session"""
232
218
  is_group: int = Field(default=0, nullable=False)
233
219
  """0 for private chat, 1 for group chat (not implemented yet)"""
234
- created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
235
- updated_at: datetime = Field(
236
- default_factory=lambda: datetime.now(timezone.utc),
237
- sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
238
- )
239
220
 
240
221
  __table_args__ = (
241
222
  UniqueConstraint(
@@ -245,7 +226,7 @@ class PlatformSession(SQLModel, table=True):
245
226
  )
246
227
 
247
228
 
248
- class Attachment(SQLModel, table=True):
229
+ class Attachment(TimestampMixin, SQLModel, table=True):
249
230
  """This class represents attachments for messages in AstrBot.
250
231
 
251
232
  Attachments can be images, files, or other media types.
@@ -267,11 +248,6 @@ class Attachment(SQLModel, table=True):
267
248
  path: str = Field(nullable=False) # Path to the file on disk
268
249
  type: str = Field(nullable=False) # Type of the file (e.g., 'image', 'file')
269
250
  mime_type: str = Field(nullable=False) # MIME type of the file
270
- created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
271
- updated_at: datetime = Field(
272
- default_factory=lambda: datetime.now(timezone.utc),
273
- sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
274
- )
275
251
 
276
252
  __table_args__ = (
277
253
  UniqueConstraint(
@@ -281,7 +257,7 @@ class Attachment(SQLModel, table=True):
281
257
  )
282
258
 
283
259
 
284
- class ChatUIProject(SQLModel, table=True):
260
+ class ChatUIProject(TimestampMixin, SQLModel, table=True):
285
261
  """This class represents projects for organizing ChatUI conversations.
286
262
 
287
263
  Projects allow users to group related conversations together.
@@ -308,11 +284,6 @@ class ChatUIProject(SQLModel, table=True):
308
284
  """Title of the project"""
309
285
  description: str | None = Field(default=None, max_length=1000)
310
286
  """Description of the project"""
311
- created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
312
- updated_at: datetime = Field(
313
- default_factory=lambda: datetime.now(timezone.utc),
314
- sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
315
- )
316
287
 
317
288
  __table_args__ = (
318
289
  UniqueConstraint(
@@ -336,7 +307,6 @@ class SessionProjectRelation(SQLModel, table=True):
336
307
  """Session ID from PlatformSession"""
337
308
  project_id: str = Field(nullable=False, max_length=36)
338
309
  """Project ID from ChatUIProject"""
339
- created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
340
310
 
341
311
  __table_args__ = (
342
312
  UniqueConstraint(
@@ -346,7 +316,7 @@ class SessionProjectRelation(SQLModel, table=True):
346
316
  )
347
317
 
348
318
 
349
- class CommandConfig(SQLModel, table=True):
319
+ class CommandConfig(TimestampMixin, SQLModel, table=True):
350
320
  """Per-command configuration overrides for dashboard management."""
351
321
 
352
322
  __tablename__ = "command_configs" # type: ignore
@@ -366,14 +336,9 @@ class CommandConfig(SQLModel, table=True):
366
336
  note: str | None = Field(default=None, sa_type=Text)
367
337
  extra_data: dict | None = Field(default=None, sa_type=JSON)
368
338
  auto_managed: bool = Field(default=False, nullable=False)
369
- created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
370
- updated_at: datetime = Field(
371
- default_factory=lambda: datetime.now(timezone.utc),
372
- sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
373
- )
374
339
 
375
340
 
376
- class CommandConflict(SQLModel, table=True):
341
+ class CommandConflict(TimestampMixin, SQLModel, table=True):
377
342
  """Conflict tracking for duplicated command names."""
378
343
 
379
344
  __tablename__ = "command_conflicts" # type: ignore
@@ -390,11 +355,6 @@ class CommandConflict(SQLModel, table=True):
390
355
  note: str | None = Field(default=None, sa_type=Text)
391
356
  extra_data: dict | None = Field(default=None, sa_type=JSON)
392
357
  auto_generated: bool = Field(default=False, nullable=False)
393
- created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
394
- updated_at: datetime = Field(
395
- default_factory=lambda: datetime.now(timezone.utc),
396
- sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
397
- )
398
358
 
399
359
  __table_args__ = (
400
360
  UniqueConstraint(
@@ -442,6 +402,8 @@ class Personality(TypedDict):
442
402
  """情感模拟对话预设。在 v4.0.0 版本及之后,已被废弃。"""
443
403
  tools: list[str] | None
444
404
  """工具列表。None 表示使用所有工具,空列表表示不使用任何工具"""
405
+ skills: list[str] | None
406
+ """Skills 列表。None 表示使用所有 Skills,空列表表示不使用任何 Skills"""
445
407
 
446
408
  # cache
447
409
  _begin_dialogs_processed: list[dict]
astrbot/core/db/sqlite.py CHANGED
@@ -52,8 +52,9 @@ class SQLiteDatabase(BaseDatabase):
52
52
  await conn.execute(text("PRAGMA temp_store=MEMORY"))
53
53
  await conn.execute(text("PRAGMA mmap_size=134217728"))
54
54
  await conn.execute(text("PRAGMA optimize"))
55
- # 确保 personas 表有 folder_idsort_order 列(前向兼容)
55
+ # 确保 personas 表有 folder_idsort_order、skills 列(前向兼容)
56
56
  await self._ensure_persona_folder_columns(conn)
57
+ await self._ensure_persona_skills_column(conn)
57
58
  await conn.commit()
58
59
 
59
60
  async def _ensure_persona_folder_columns(self, conn) -> None:
@@ -76,6 +77,18 @@ class SQLiteDatabase(BaseDatabase):
76
77
  text("ALTER TABLE personas ADD COLUMN sort_order INTEGER DEFAULT 0")
77
78
  )
78
79
 
80
+ async def _ensure_persona_skills_column(self, conn) -> None:
81
+ """确保 personas 表有 skills 列。
82
+
83
+ 这是为了支持旧版数据库的平滑升级。新版数据库通过 SQLModel
84
+ 的 metadata.create_all 自动创建这些列。
85
+ """
86
+ result = await conn.execute(text("PRAGMA table_info(personas)"))
87
+ columns = {row[1] for row in result.fetchall()}
88
+
89
+ if "skills" not in columns:
90
+ await conn.execute(text("ALTER TABLE personas ADD COLUMN skills JSON"))
91
+
79
92
  # ====
80
93
  # Platform Statistics
81
94
  # ====
@@ -564,6 +577,7 @@ class SQLiteDatabase(BaseDatabase):
564
577
  system_prompt,
565
578
  begin_dialogs=None,
566
579
  tools=None,
580
+ skills=None,
567
581
  folder_id=None,
568
582
  sort_order=0,
569
583
  ):
@@ -576,6 +590,7 @@ class SQLiteDatabase(BaseDatabase):
576
590
  system_prompt=system_prompt,
577
591
  begin_dialogs=begin_dialogs or [],
578
592
  tools=tools,
593
+ skills=skills,
579
594
  folder_id=folder_id,
580
595
  sort_order=sort_order,
581
596
  )
@@ -606,6 +621,7 @@ class SQLiteDatabase(BaseDatabase):
606
621
  system_prompt=None,
607
622
  begin_dialogs=None,
608
623
  tools=NOT_GIVEN,
624
+ skills=NOT_GIVEN,
609
625
  ):
610
626
  """Update a persona's system prompt or begin dialogs."""
611
627
  async with self.get_db() as session:
@@ -619,6 +635,8 @@ class SQLiteDatabase(BaseDatabase):
619
635
  values["begin_dialogs"] = begin_dialogs
620
636
  if tools is not NOT_GIVEN:
621
637
  values["tools"] = tools
638
+ if skills is not NOT_GIVEN:
639
+ values["skills"] = skills
622
640
  if not values:
623
641
  return None
624
642
  query = query.values(**values)
@@ -567,7 +567,7 @@ class Node(BaseMessageComponent):
567
567
  async def to_dict(self):
568
568
  data_content = []
569
569
  for comp in self.content:
570
- if isinstance(comp, (Image, Record)):
570
+ if isinstance(comp, Image | Record):
571
571
  # For Image and Record segments, we convert them to base64
572
572
  bs64 = await comp.convert_to_base64()
573
573
  data_content.append(
@@ -584,7 +584,7 @@ class Node(BaseMessageComponent):
584
584
  # For File segments, we need to handle the file differently
585
585
  d = await comp.to_dict()
586
586
  data_content.append(d)
587
- elif isinstance(comp, (Node, Nodes)):
587
+ elif isinstance(comp, Node | Nodes):
588
588
  # For Node segments, we recursively convert them to dict
589
589
  d = await comp.to_dict()
590
590
  data_content.append(d)
@@ -10,6 +10,7 @@ DEFAULT_PERSONALITY = Personality(
10
10
  begin_dialogs=[],
11
11
  mood_imitation_dialogs=[],
12
12
  tools=None,
13
+ skills=None,
13
14
  _begin_dialogs_processed=[],
14
15
  _mood_imitation_dialogs_processed="",
15
16
  )
@@ -71,6 +72,7 @@ class PersonaManager:
71
72
  system_prompt: str | None = None,
72
73
  begin_dialogs: list[str] | None = None,
73
74
  tools: list[str] | None = None,
75
+ skills: list[str] | None = None,
74
76
  ):
75
77
  """更新指定 persona 的信息。tools 参数为 None 时表示使用所有工具,空列表表示不使用任何工具"""
76
78
  existing_persona = await self.db.get_persona_by_id(persona_id)
@@ -81,6 +83,7 @@ class PersonaManager:
81
83
  system_prompt,
82
84
  begin_dialogs,
83
85
  tools=tools,
86
+ skills=skills,
84
87
  )
85
88
  if persona:
86
89
  for i, p in enumerate(self.personas):
@@ -239,6 +242,7 @@ class PersonaManager:
239
242
  system_prompt: str,
240
243
  begin_dialogs: list[str] | None = None,
241
244
  tools: list[str] | None = None,
245
+ skills: list[str] | None = None,
242
246
  folder_id: str | None = None,
243
247
  sort_order: int = 0,
244
248
  ) -> Persona:
@@ -249,6 +253,7 @@ class PersonaManager:
249
253
  system_prompt: 系统提示词
250
254
  begin_dialogs: 预设对话列表
251
255
  tools: 工具列表,None 表示使用所有工具,空列表表示不使用任何工具
256
+ skills: Skills 列表,None 表示使用所有 Skills,空列表表示不使用任何 Skills
252
257
  folder_id: 所属文件夹 ID,None 表示根目录
253
258
  sort_order: 排序顺序
254
259
  """
@@ -259,6 +264,7 @@ class PersonaManager:
259
264
  system_prompt,
260
265
  begin_dialogs,
261
266
  tools=tools,
267
+ skills=skills,
262
268
  folder_id=folder_id,
263
269
  sort_order=sort_order,
264
270
  )
@@ -284,6 +290,7 @@ class PersonaManager:
284
290
  "begin_dialogs": persona.begin_dialogs or [],
285
291
  "mood_imitation_dialogs": [], # deprecated
286
292
  "tools": persona.tools,
293
+ "skills": persona.skills,
287
294
  }
288
295
  for persona in self.personas
289
296
  ]
@@ -339,6 +346,7 @@ class PersonaManager:
339
346
  system_prompt=selected_default_persona["prompt"],
340
347
  begin_dialogs=selected_default_persona["begin_dialogs"],
341
348
  tools=selected_default_persona["tools"] or None,
349
+ skills=selected_default_persona["skills"] or None,
342
350
  )
343
351
 
344
352
  return v3_persona_config, personas_v3, selected_default_persona
@@ -48,7 +48,7 @@ async def call_handler(
48
48
  # 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码
49
49
  # 返回值只能是 MessageEventResult 或者 None(无返回值)
50
50
  _has_yielded = True
51
- if isinstance(ret, (MessageEventResult, CommandResult)):
51
+ if isinstance(ret, MessageEventResult | CommandResult):
52
52
  # 如果返回值是 MessageEventResult, 设置结果并继续
53
53
  event.set_result(ret)
54
54
  yield
@@ -65,7 +65,7 @@ async def call_handler(
65
65
  elif inspect.iscoroutine(ready_to_call):
66
66
  # 如果只是一个协程, 直接执行
67
67
  ret = await ready_to_call
68
- if isinstance(ret, (MessageEventResult, CommandResult)):
68
+ if isinstance(ret, MessageEventResult | CommandResult):
69
69
  event.set_result(ret)
70
70
  yield
71
71
  else:
@@ -52,7 +52,7 @@ class PreProcessStage(Stage):
52
52
  message_chain = event.get_messages()
53
53
 
54
54
  for idx, component in enumerate(message_chain):
55
- if isinstance(component, (Record, Image)) and component.url:
55
+ if isinstance(component, Record | Image) and component.url:
56
56
  for mapping in mappings:
57
57
  from_, to_ = mapping.split(":")
58
58
  from_ = from_.removesuffix("/")
@@ -46,6 +46,7 @@ from ...utils import (
46
46
  PYTHON_TOOL,
47
47
  SANDBOX_MODE_PROMPT,
48
48
  TOOL_CALL_PROMPT,
49
+ TOOL_CALL_PROMPT_SKILLS_LIKE_MODE,
49
50
  decoded_blocked,
50
51
  retrieve_knowledge_base,
51
52
  )
@@ -62,6 +63,13 @@ class InternalAgentSubStage(Stage):
62
63
  ]
63
64
  self.max_step: int = settings.get("max_agent_step", 30)
64
65
  self.tool_call_timeout: int = settings.get("tool_call_timeout", 60)
66
+ self.tool_schema_mode: str = settings.get("tool_schema_mode", "full")
67
+ if self.tool_schema_mode not in ("skills_like", "full"):
68
+ logger.warning(
69
+ "Unsupported tool_schema_mode: %s, fallback to skills_like",
70
+ self.tool_schema_mode,
71
+ )
72
+ self.tool_schema_mode = "full"
65
73
  if isinstance(self.max_step, bool): # workaround: #2622
66
74
  self.max_step = 30
67
75
  self.show_tool_use: bool = settings.get("show_tool_use_status", True)
@@ -517,7 +525,7 @@ class InternalAgentSubStage(Stage):
517
525
  has_valid_message = bool(event.message_str and event.message_str.strip())
518
526
  # 检查是否有图片或其他媒体内容
519
527
  has_media_content = any(
520
- isinstance(comp, (Image, File)) for comp in event.message_obj.message
528
+ isinstance(comp, Image | File) for comp in event.message_obj.message
521
529
  )
522
530
 
523
531
  if (
@@ -574,9 +582,7 @@ class InternalAgentSubStage(Stage):
574
582
  req.extra_user_content_parts.append(
575
583
  TextPart(text=f"[Image Attachment: path {image_path}]")
576
584
  )
577
- elif isinstance(comp, File) and self.sandbox_cfg.get(
578
- "enable", False
579
- ):
585
+ elif isinstance(comp, File):
580
586
  file_path = await comp.get_file()
581
587
  file_name = comp.name or os.path.basename(file_path)
582
588
  req.extra_user_content_parts.append(
@@ -603,7 +609,10 @@ class InternalAgentSubStage(Stage):
603
609
  logger.error(f"Error occurred while applying file extract: {e}")
604
610
 
605
611
  if not req.prompt and not req.image_urls:
606
- return
612
+ if not event.get_group_id() and req.extra_user_content_parts:
613
+ req.prompt = "<attachment>"
614
+ else:
615
+ return
607
616
 
608
617
  # call event hook
609
618
  if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
@@ -672,7 +681,12 @@ class InternalAgentSubStage(Stage):
672
681
 
673
682
  # 注入基本 prompt
674
683
  if req.func_tool and req.func_tool.tools:
675
- req.system_prompt += f"\n{TOOL_CALL_PROMPT}\n"
684
+ tool_prompt = (
685
+ TOOL_CALL_PROMPT
686
+ if self.tool_schema_mode == "full"
687
+ else TOOL_CALL_PROMPT_SKILLS_LIKE_MODE
688
+ )
689
+ req.system_prompt += f"\n{tool_prompt}\n"
676
690
 
677
691
  action_type = event.get_extra("action_type")
678
692
  if action_type == "live":
@@ -693,6 +707,7 @@ class InternalAgentSubStage(Stage):
693
707
  llm_compress_provider=self._get_compress_provider(),
694
708
  truncate_turns=self.dequeue_context_length,
695
709
  enforce_max_turns=self.max_context_length,
710
+ tool_schema_mode=self.tool_schema_mode,
696
711
  )
697
712
 
698
713
  # 检测 Live Mode
@@ -7,10 +7,11 @@ from astrbot.api import logger, sp
7
7
  from astrbot.core.agent.run_context import ContextWrapper
8
8
  from astrbot.core.agent.tool import FunctionTool, ToolExecResult
9
9
  from astrbot.core.astr_agent_context import AstrAgentContext
10
- from astrbot.core.sandbox.tools import (
10
+ from astrbot.core.computer.tools import (
11
11
  ExecuteShellTool,
12
12
  FileDownloadTool,
13
13
  FileUploadTool,
14
+ LocalPythonTool,
14
15
  PythonTool,
15
16
  )
16
17
  from astrbot.core.star.context import Context
@@ -39,11 +40,23 @@ SANDBOX_MODE_PROMPT = (
39
40
 
40
41
  TOOL_CALL_PROMPT = (
41
42
  "You MUST NOT return an empty response, especially after invoking a tool."
42
- "Before calling any tool, provide a brief explanatory message to the user stating the purpose of the tool call."
43
- "After the tool call is completed, you must briefly summarize the results returned by the tool for the user."
44
- "Keep the role-play and style consistent throughout the conversation."
43
+ " Before calling any tool, provide a brief explanatory message to the user stating the purpose of the tool call."
44
+ " Use the provided tool schema to format arguments and do not guess parameters that are not defined."
45
+ " After the tool call is completed, you must briefly summarize the results returned by the tool for the user."
46
+ " Keep the role-play and style consistent throughout the conversation."
45
47
  )
46
48
 
49
+ TOOL_CALL_PROMPT_SKILLS_LIKE_MODE = (
50
+ "You MUST NOT return an empty response, especially after invoking a tool."
51
+ " Before calling any tool, provide a brief explanatory message to the user stating the purpose of the tool call."
52
+ " Tool schemas are provided in two stages: first only name and description; "
53
+ "if you decide to use a tool, the full parameter schema will be provided in "
54
+ "a follow-up step. Do not guess arguments before you see the schema."
55
+ " After the tool call is completed, you must briefly summarize the results returned by the tool for the user."
56
+ " Keep the role-play and style consistent throughout the conversation."
57
+ )
58
+
59
+
47
60
  CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT = (
48
61
  "You are a calm, patient friend with a systems-oriented way of thinking.\n"
49
62
  "When someone expresses strong emotional needs, you begin by offering a concise, grounding response "
@@ -194,7 +207,9 @@ async def retrieve_knowledge_base(
194
207
  KNOWLEDGE_BASE_QUERY_TOOL = KnowledgeBaseQueryTool()
195
208
 
196
209
  EXECUTE_SHELL_TOOL = ExecuteShellTool()
210
+ LOCAL_EXECUTE_SHELL_TOOL = ExecuteShellTool(is_local=True)
197
211
  PYTHON_TOOL = PythonTool()
212
+ LOCAL_PYTHON_TOOL = LocalPythonTool()
198
213
  FILE_UPLOAD_TOOL = FileUploadTool()
199
214
  FILE_DOWNLOAD_TOOL = FileDownloadTool()
200
215
 
@@ -82,7 +82,7 @@ class PipelineScheduler:
82
82
  await self._process_stages(event)
83
83
 
84
84
  # 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
85
- if isinstance(event, (WebChatMessageEvent, WecomAIBotMessageEvent)):
85
+ if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
86
86
  await event.send(None)
87
87
 
88
88
  logger.debug("pipeline 执行完毕。")
@@ -33,7 +33,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
33
33
  @staticmethod
34
34
  async def _from_segment_to_dict(segment: BaseMessageComponent) -> dict:
35
35
  """修复部分字段"""
36
- if isinstance(segment, (Image, Record)):
36
+ if isinstance(segment, Image | Record):
37
37
  # For Image and Record segments, we convert them to base64
38
38
  bs64 = await segment.convert_to_base64()
39
39
  return {
@@ -110,7 +110,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
110
110
  """
111
111
  # 转发消息、文件消息不能和普通消息混在一起发送
112
112
  send_one_by_one = any(
113
- isinstance(seg, (Node, Nodes, File)) for seg in message_chain.chain
113
+ isinstance(seg, Node | Nodes | File) for seg in message_chain.chain
114
114
  )
115
115
  if not send_one_by_one:
116
116
  ret = await cls._parse_onebot_json(message_chain)
@@ -119,7 +119,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
119
119
  await cls._dispatch_send(bot, event, is_group, session_id, ret)
120
120
  return
121
121
  for seg in message_chain.chain:
122
- if isinstance(seg, (Node, Nodes)):
122
+ if isinstance(seg, Node | Nodes):
123
123
  # 合并转发消息
124
124
  if isinstance(seg, Node):
125
125
  nodes = Nodes([seg])
@@ -90,12 +90,10 @@ class QQOfficialMessageEvent(AstrMessageEvent):
90
90
 
91
91
  if not isinstance(
92
92
  source,
93
- (
94
- botpy.message.Message,
95
- botpy.message.GroupMessage,
96
- botpy.message.DirectMessage,
97
- botpy.message.C2CMessage,
98
- ),
93
+ botpy.message.Message
94
+ | botpy.message.GroupMessage
95
+ | botpy.message.DirectMessage
96
+ | botpy.message.C2CMessage,
99
97
  ):
100
98
  logger.warning(f"[QQOfficial] 不支持的消息源类型: {type(source)}")
101
99
  return None
@@ -120,7 +118,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
120
118
  "msg_id": self.message_obj.message_id,
121
119
  }
122
120
 
123
- if not isinstance(source, (botpy.message.Message, botpy.message.DirectMessage)):
121
+ if not isinstance(source, botpy.message.Message | botpy.message.DirectMessage):
124
122
  payload["msg_seq"] = random.randint(1, 10000)
125
123
 
126
124
  ret = None
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import copy
3
+ import os
3
4
  import traceback
4
5
  from typing import Protocol, runtime_checkable
5
6
 
@@ -406,10 +407,40 @@ class ProviderManager:
406
407
  pc = merged_config
407
408
  return pc
408
409
 
410
+ def _resolve_env_key_list(self, provider_config: dict) -> dict:
411
+ keys = provider_config.get("key", [])
412
+ if not isinstance(keys, list):
413
+ return provider_config
414
+ resolved_keys = []
415
+ for idx, key in enumerate(keys):
416
+ if isinstance(key, str) and key.startswith("$"):
417
+ env_key = key[1:]
418
+ if env_key.startswith("{") and env_key.endswith("}"):
419
+ env_key = env_key[1:-1]
420
+ if env_key:
421
+ env_val = os.getenv(env_key)
422
+ if env_val is None:
423
+ provider_id = provider_config.get("id")
424
+ logger.warning(
425
+ f"Provider {provider_id} 配置项 key[{idx}] 使用环境变量 {env_key} 但未设置。",
426
+ )
427
+ resolved_keys.append("")
428
+ else:
429
+ resolved_keys.append(env_val)
430
+ else:
431
+ resolved_keys.append(key)
432
+ else:
433
+ resolved_keys.append(key)
434
+ provider_config["key"] = resolved_keys
435
+ return provider_config
436
+
409
437
  async def load_provider(self, provider_config: dict):
410
438
  # 如果 provider_source_id 存在且不为空,则从 provider_sources 中找到对应的配置并合并
411
439
  provider_config = self.get_merged_provider_config(provider_config)
412
440
 
441
+ if provider_config.get("provider_type", "") == "chat_completion":
442
+ provider_config = self._resolve_env_key_list(provider_config)
443
+
413
444
  if not provider_config["enable"]:
414
445
  logger.info(f"Provider {provider_config['id']} is disabled, skipping")
415
446
  return
@@ -382,15 +382,18 @@ class ProviderGoogleGenAI(Provider):
382
382
  append_or_extend(gemini_contents, parts, types.ModelContent)
383
383
 
384
384
  elif role == "tool" and not native_tool_enabled:
385
- parts = [
386
- types.Part.from_function_response(
387
- name=message["tool_call_id"],
388
- response={
389
- "name": message["tool_call_id"],
390
- "content": message["content"],
391
- },
392
- ),
393
- ]
385
+ func_name = message.get("name", message["tool_call_id"])
386
+ part = types.Part.from_function_response(
387
+ name=func_name,
388
+ response={
389
+ "name": func_name,
390
+ "content": message["content"],
391
+ },
392
+ )
393
+ if part.function_response:
394
+ part.function_response.id = message["tool_call_id"]
395
+
396
+ parts = [part]
394
397
  append_or_extend(gemini_contents, parts, types.UserContent)
395
398
 
396
399
  if gemini_contents and isinstance(gemini_contents[0], types.ModelContent):