From 6b39717695e777b9a029d0291a24fd981d1cc6f8 Mon Sep 17 00:00:00 2001
From: advent259141 <2968474907@qq.com>
Date: Mon, 26 Jan 2026 14:57:20 +0800
Subject: [PATCH 01/24] =?UTF-8?q?=E5=A2=9E=E5=8A=A0subagent=E7=BC=96?=
=?UTF-8?q?=E6=8E=92=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../astrbot/process_llm_request.py | 27 ++
astrbot/core/config/default.py | 13 +
astrbot/core/core_lifecycle.py | 24 ++
astrbot/core/subagent_orchestrator.py | 102 +++++
astrbot/dashboard/routes/__init__.py | 2 +
astrbot/dashboard/routes/subagent.py | 98 +++++
astrbot/dashboard/server.py | 2 +
dashboard/package.json | 3 +-
.../i18n/locales/en-US/core/navigation.json | 1 +
.../i18n/locales/zh-CN/core/navigation.json | 1 +
.../full/vertical-sidebar/sidebarItem.ts | 5 +
dashboard/src/router/MainRoutes.ts | 5 +
dashboard/src/views/SubAgentPage.vue | 353 ++++++++++++++++++
13 files changed, 635 insertions(+), 1 deletion(-)
create mode 100644 astrbot/core/subagent_orchestrator.py
create mode 100644 astrbot/dashboard/routes/subagent.py
create mode 100644 dashboard/src/views/SubAgentPage.vue
diff --git a/astrbot/builtin_stars/astrbot/process_llm_request.py b/astrbot/builtin_stars/astrbot/process_llm_request.py
index 4e8c562cd8..547b12410f 100644
--- a/astrbot/builtin_stars/astrbot/process_llm_request.py
+++ b/astrbot/builtin_stars/astrbot/process_llm_request.py
@@ -11,6 +11,7 @@
from astrbot.core.pipeline.process_stage.utils import (
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT,
)
+from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.provider.func_tool_manager import ToolSet
@@ -68,6 +69,32 @@ async def _ensure_persona(
# tools select
tmgr = self.ctx.get_llm_tool_manager()
+
+ # SubAgent orchestrator mode: main LLM only sees handoff tools.
+ orch_cfg = cfg.get("subagent_orchestrator", {})
+ if orch_cfg.get("main_enable", False):
+ toolset = ToolSet()
+ for tool in tmgr.func_list:
+ if isinstance(tool, HandoffTool) and tool.active:
+ toolset.add_tool(tool)
+ req.func_tool = toolset
+
+ # Encourage the model to delegate to subagents.
+ # Use the built-in default router prompt; user overrides are disabled for now.
+ router_prompt = (
+ self.ctx.get_config().get("provider_settings", {})
+ .get("subagent_orchestrator", {})
+ .get("router_system_prompt", "")
+ ).strip()
+ if router_prompt:
+ req.system_prompt += f"\n{router_prompt}\n"
+
+ logger.debug(
+ f"Subagent orchestrator enabled; main tool set (handoff_only): {toolset.names()}"
+ )
+ return
+
+ # Default behavior: follow persona tool selection.
if (persona and persona.get("tools") is None) or not persona:
# select all
toolset = tmgr.get_full_tool_set()
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index f609132688..e14187a1c5 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -121,6 +121,19 @@
"shipyard_ttl": 3600,
"shipyard_max_sessions": 10,
},
+
+ # SubAgent orchestrator mode: the main LLM only delegates tasks to subagents
+ # (via transfer_to_{agent} tools). Domain tools are mounted on subagents.
+ "subagent_orchestrator": {
+ "main_enable": False,
+ "main_tools_policy": "handoff_only", # reserved for future; main_enable implies handoff_only
+ "router_system_prompt": (
+ "You are a task router. Your job is to chat naturally, recognize user intent, "
+ "and delegate work to the most suitable subagent using transfer_to_* tools. "
+ "Do not try to use domain tools yourself. If no subagent fits, respond directly."
+ ),
+ "agents": [],
+ },
},
"provider_stt_settings": {
"enable": False,
diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py
index a14d8d9705..942e9b7d57 100644
--- a/astrbot/core/core_lifecycle.py
+++ b/astrbot/core/core_lifecycle.py
@@ -35,6 +35,7 @@
from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils.llm_metadata import update_llm_metadata
from astrbot.core.utils.migra_helper import migra
+from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
from . import astrbot_config, html_renderer
from .event_bus import EventBus
@@ -53,6 +54,10 @@ def __init__(self, log_broker: LogBroker, db: BaseDatabase) -> None:
self.astrbot_config = astrbot_config # 初始化配置
self.db = db # 初始化数据库
+ # Optional orchestrator that registers dynamic handoff tools (transfer_to_*)
+ # from provider_settings.subagent_orchestrator.
+ self.subagent_orchestrator: SubAgentOrchestrator | None = None
+
# 设置代理
proxy_config = self.astrbot_config.get("http_proxy", "")
if proxy_config != "":
@@ -72,6 +77,23 @@ def __init__(self, log_broker: LogBroker, db: BaseDatabase) -> None:
del os.environ["no_proxy"]
logger.debug("HTTP proxy cleared")
+ def _init_or_reload_subagent_orchestrator(self) -> None:
+ """Create (if needed) and reload the subagent orchestrator from config.
+
+ This keeps lifecycle wiring in one place while allowing the orchestrator
+ to manage enable/disable and tool registration details.
+ """
+ try:
+ if self.subagent_orchestrator is None:
+ self.subagent_orchestrator = SubAgentOrchestrator(
+ self.provider_manager.llm_tools,
+ )
+ self.subagent_orchestrator.reload_from_config(
+ self.astrbot_config.get("provider_settings", {}),
+ )
+ except Exception as e:
+ logger.error(f"Subagent orchestrator init failed: {e}", exc_info=True)
+
async def initialize(self) -> None:
"""初始化 AstrBot 核心生命周期管理类.
@@ -175,6 +197,8 @@ async def initialize(self) -> None:
self.astrbot_config_mgr,
)
+ # Dynamic subagents (handoff tools) from config.
+ self._init_or_reload_subagent_orchestrator()
# 记录启动时间
self.start_time = int(time.time())
diff --git a/astrbot/core/subagent_orchestrator.py b/astrbot/core/subagent_orchestrator.py
new file mode 100644
index 0000000000..f160672221
--- /dev/null
+++ b/astrbot/core/subagent_orchestrator.py
@@ -0,0 +1,102 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from astrbot import logger
+from astrbot.core.agent.agent import Agent
+from astrbot.core.agent.handoff import HandoffTool
+from astrbot.core.astr_agent_context import AstrAgentContext
+from astrbot.core.provider.func_tool_manager import FunctionToolManager
+
+
+@dataclass(frozen=True)
+class SubAgentConfig:
+ """Runtime representation of a configured subagent."""
+
+ name: str
+ instructions: str
+ tools: list[str]
+ enabled: bool = True
+
+
+class SubAgentOrchestrator:
+ """Loads subagent definitions from config and registers handoff tools.
+
+ This is intentionally lightweight: it does not execute agents itself.
+ Execution happens via HandoffTool in FunctionToolExecutor.
+ """
+
+ def __init__(self, tool_mgr: FunctionToolManager):
+ self._tool_mgr = tool_mgr
+ self._registered_handoff_names: set[str] = set()
+
+ def reload_from_config(self, provider_settings: dict[str, Any]) -> None:
+ cfg = provider_settings.get("subagent_orchestrator", {})
+ enabled = bool(cfg.get("main_enable", False))
+
+ # Remove previously registered dynamic handoff tools.
+ if self._registered_handoff_names:
+ for name in list(self._registered_handoff_names):
+ try:
+ self._tool_mgr.remove_func(name)
+ except Exception:
+ # remove_func is best-effort; keep going.
+ pass
+ self._registered_handoff_names.clear()
+
+ if not enabled:
+ return
+
+ agents = cfg.get("agents", [])
+ if not isinstance(agents, list):
+ logger.warning("subagent_orchestrator.agents must be a list")
+ return
+
+ for item in agents:
+ if not isinstance(item, dict):
+ continue
+ if not item.get("enabled", True):
+ continue
+
+ name = str(item.get("name", "")).strip()
+ if not name:
+ continue
+
+ instructions = str(item.get("description", "")).strip()
+ tools = item.get("tools", [])
+ if not isinstance(tools, list):
+ tools = []
+ tools = [str(t).strip() for t in tools if str(t).strip()]
+
+ agent = Agent[AstrAgentContext](
+ name=name,
+ instructions=instructions,
+ tools=tools,
+ )
+ handoff = HandoffTool(agent=agent)
+
+ # Mark as dynamic so we can replace/remove later.
+ handoff.handler_module_path = "core.subagent_orchestrator"
+
+ # Register tool (replaces if same name exists).
+ self._tool_mgr.add_func(
+ name=handoff.name,
+ func_args=[
+ {
+ "type": "string",
+ "name": "input",
+ "description": "Task input delegated from the main agent.",
+ }
+ ],
+ desc=handoff.description,
+ handler=handoff.handler,
+ )
+
+ # NOTE: add_func wraps handler into a FunctionTool; we want the actual HandoffTool.
+ # Therefore, directly append the HandoffTool to func_list (and remove any wrapper).
+ self._tool_mgr.remove_func(handoff.name)
+ self._tool_mgr.func_list.append(handoff)
+
+ self._registered_handoff_names.add(handoff.name)
+ logger.info(f"Registered subagent handoff tool: {handoff.name}")
diff --git a/astrbot/dashboard/routes/__init__.py b/astrbot/dashboard/routes/__init__.py
index 908bbfcc34..cffbe5156a 100644
--- a/astrbot/dashboard/routes/__init__.py
+++ b/astrbot/dashboard/routes/__init__.py
@@ -14,6 +14,7 @@
from .session_management import SessionManagementRoute
from .stat import StatRoute
from .static_file import StaticFileRoute
+from .subagent import SubAgentRoute
from .tools import ToolsRoute
from .update import UpdateRoute
@@ -34,6 +35,7 @@
"SessionManagementRoute",
"StatRoute",
"StaticFileRoute",
+ "SubAgentRoute",
"ToolsRoute",
"UpdateRoute",
]
diff --git a/astrbot/dashboard/routes/subagent.py b/astrbot/dashboard/routes/subagent.py
new file mode 100644
index 0000000000..08bbb8ddad
--- /dev/null
+++ b/astrbot/dashboard/routes/subagent.py
@@ -0,0 +1,98 @@
+import traceback
+
+from quart import request
+
+from astrbot.core import logger
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+
+from .route import Response, Route, RouteContext
+
+
+class SubAgentRoute(Route):
+ def __init__(
+ self,
+ context: RouteContext,
+ core_lifecycle: AstrBotCoreLifecycle,
+ ) -> None:
+ super().__init__(context)
+ self.core_lifecycle = core_lifecycle
+ self.routes = {
+ "/subagent/config": ("GET", self.get_config),
+ "/subagent/config": ("POST", self.update_config),
+ "/subagent/available-tools": ("GET", self.get_available_tools),
+ }
+ self.register_routes()
+
+ async def get_config(self):
+ try:
+ cfg = self.core_lifecycle.astrbot_config
+ provider_settings = cfg.get("provider_settings", {})
+ data = provider_settings.get("subagent_orchestrator")
+
+ # First-time access: return a sane default instead of erroring.
+ if not isinstance(data, dict):
+ data = {
+ "main_enable": False,
+ "main_tools_policy": "handoff_only",
+ "agents": [],
+ }
+
+ # Backward compatibility: older config used `enable`.
+ if isinstance(data, dict) and "main_enable" not in data and "enable" in data:
+ data["main_enable"] = bool(data.get("enable", False))
+
+ # Ensure required keys exist.
+ data.setdefault("main_enable", False)
+ data.setdefault("main_tools_policy", "handoff_only")
+ data.setdefault("agents", [])
+ return Response().ok(data=data).__dict__
+ except Exception as e:
+ logger.error(traceback.format_exc())
+ return Response().error(f"获取 subagent 配置失败: {e!s}").__dict__
+
+ async def update_config(self):
+ try:
+ data = await request.json
+ if not isinstance(data, dict):
+ return Response().error("配置必须为 JSON 对象").__dict__
+
+ cfg = self.core_lifecycle.astrbot_config
+ provider_settings = cfg.get("provider_settings", {})
+ provider_settings["subagent_orchestrator"] = data
+ cfg["provider_settings"] = provider_settings
+
+ # Persist to cmd_config.json
+ self.core_lifecycle.astrbot_config_mgr.save(cfg)
+
+ # Reload dynamic handoff tools if orchestrator exists
+ orch = getattr(self.core_lifecycle, "subagent_orchestrator", None)
+ if orch is not None:
+ orch.reload_from_config(provider_settings)
+
+ return Response().ok(message="保存成功").__dict__
+ except Exception as e:
+ logger.error(traceback.format_exc())
+ return Response().error(f"保存 subagent 配置失败: {e!s}").__dict__
+
+ async def get_available_tools(self):
+ """Return all registered tools (name/description/parameters/active/origin).
+
+ UI can use this to build a multi-select list for subagent tool assignment.
+ """
+ try:
+ tool_mgr = self.core_lifecycle.provider_manager.llm_tools
+ tools_dict = []
+ for tool in tool_mgr.func_list:
+ tools_dict.append(
+ {
+ "name": tool.name,
+ "description": tool.description,
+ "parameters": tool.parameters,
+ "active": tool.active,
+ "handler_module_path": tool.handler_module_path,
+ }
+ )
+ return Response().ok(data=tools_dict).__dict__
+ except Exception as e:
+ logger.error(traceback.format_exc())
+ return Response().error(f"获取可用工具失败: {e!s}").__dict__
diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py
index 08fca73a9f..5b5abdfeb0 100644
--- a/astrbot/dashboard/server.py
+++ b/astrbot/dashboard/server.py
@@ -26,6 +26,7 @@
from .routes.platform import PlatformRoute
from .routes.route import Response, RouteContext
from .routes.session_management import SessionManagementRoute
+from .routes.subagent import SubAgentRoute
from .routes.t2i import T2iRoute
APP: Quart
@@ -79,6 +80,7 @@ def __init__(
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
self.tools_root = ToolsRoute(self.context, core_lifecycle)
+ self.subagent_route = SubAgentRoute(self.context, core_lifecycle)
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
self.file_route = FileRoute(self.context)
self.session_management_route = SessionManagementRoute(
diff --git a/dashboard/package.json b/dashboard/package.json
index 2ce6668e3a..f4d5df9385 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -30,6 +30,7 @@
"markdown-it": "^14.1.0",
"markstream-vue": "^0.0.6-beta.1",
"mermaid": "^11.12.2",
+ "monaco-editor": "^0.55.1",
"pinia": "2.1.6",
"pinyin-pro": "^3.26.0",
"remixicon": "3.5.0",
@@ -68,4 +69,4 @@
"vue-tsc": "1.8.8",
"vuetify-loader": "^2.0.0-alpha.9"
}
-}
\ No newline at end of file
+}
diff --git a/dashboard/src/i18n/locales/en-US/core/navigation.json b/dashboard/src/i18n/locales/en-US/core/navigation.json
index 52f1eb1105..947e93e47b 100644
--- a/dashboard/src/i18n/locales/en-US/core/navigation.json
+++ b/dashboard/src/i18n/locales/en-US/core/navigation.json
@@ -4,6 +4,7 @@
"providers": "Providers",
"commands": "Commands",
"persona": "Persona",
+ "subagent": "SubAgents",
"toolUse": "MCP Tools",
"config": "Config",
"chat": "Chat",
diff --git a/dashboard/src/i18n/locales/zh-CN/core/navigation.json b/dashboard/src/i18n/locales/zh-CN/core/navigation.json
index 519de9c254..1e2155c638 100644
--- a/dashboard/src/i18n/locales/zh-CN/core/navigation.json
+++ b/dashboard/src/i18n/locales/zh-CN/core/navigation.json
@@ -4,6 +4,7 @@
"providers": "模型提供商",
"commands": "指令管理",
"persona": "人格设定",
+ "subagent": "SubAgent 编排",
"toolUse": "MCP",
"extension": "插件",
"config": "配置文件",
diff --git a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts
index 3972dd9aad..2dca576f1a 100644
--- a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts
+++ b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts
@@ -52,6 +52,11 @@ const sidebarItem: menu[] = [
icon: 'mdi-heart',
to: '/persona'
},
+ {
+ title: 'core.navigation.subagent',
+ icon: 'mdi-vector-link',
+ to: '/subagent'
+ },
{
title: 'core.navigation.conversation',
icon: 'mdi-database',
diff --git a/dashboard/src/router/MainRoutes.ts b/dashboard/src/router/MainRoutes.ts
index 0a8617426e..a6657c5edb 100644
--- a/dashboard/src/router/MainRoutes.ts
+++ b/dashboard/src/router/MainRoutes.ts
@@ -56,6 +56,11 @@ const MainRoutes = {
path: '/persona',
component: () => import('@/views/PersonaPage.vue')
},
+ {
+ name: 'SubAgent',
+ path: '/subagent',
+ component: () => import('@/views/SubAgentPage.vue')
+ },
{
name: 'Console',
path: '/console',
diff --git a/dashboard/src/views/SubAgentPage.vue b/dashboard/src/views/SubAgentPage.vue
new file mode 100644
index 0000000000..625fcb0ea3
--- /dev/null
+++ b/dashboard/src/views/SubAgentPage.vue
@@ -0,0 +1,353 @@
+
+
+
+
+
SubAgent 编排
+
+ 主 LLM 只负责聊天与分派(handoff),工具挂载在各个 SubAgent 上。
+
+
+
+
+ 刷新
+ 保存
+
+
+
+
+
+
+
+
+
+
+
+
+ 启用后:主 LLM 只会看到 transfer_to_*,不会直接注入/调用其他工具;所有工具调用交给 SubAgent 完成。
+ 关闭后:恢复原有行为(按 persona 选择并直接注入工具)。
+
+
+
+ Router Prompt 当前使用系统内置默认值,暂不支持在 WebUI 中自定义。
+
+
+
+
SubAgents
+
+ 新增 SubAgent
+
+
+
+
+
+
+
+
+
+ {{ agent.enabled ? '启用' : '停用' }}
+
+
+ {{ agent.name || '未命名 SubAgent' }}
+
+
+
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
预览:主 LLM 将看到的 handoff 工具
+
+
+ transfer_to_{{ agent.name || '...' }}
+
+
+ {{ t }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ snackbar.message }}
+
+
+
+
+
+
+
From 6d47663842a5072c182f6a2f17e21d3c73db5eff Mon Sep 17 00:00:00 2001
From: advent259141 <2968474907@qq.com>
Date: Mon, 26 Jan 2026 17:22:20 +0800
Subject: [PATCH 02/24] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E4=B8=80?=
=?UTF-8?q?=E4=BA=9B=E5=B7=B2=E7=9F=A5=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
astrbot/core/agent/handoff.py | 11 +++++-
astrbot/core/subagent_orchestrator.py | 10 ++++-
astrbot/dashboard/routes/subagent.py | 30 ++++++++------
astrbot/dashboard/server.py | 3 +-
dashboard/src/main.ts | 14 +++++++
dashboard/src/views/SubAgentPage.vue | 57 +++++++++++++++++++++++----
6 files changed, 100 insertions(+), 25 deletions(-)
diff --git a/astrbot/core/agent/handoff.py b/astrbot/core/agent/handoff.py
index 85276540b5..511fb53995 100644
--- a/astrbot/core/agent/handoff.py
+++ b/astrbot/core/agent/handoff.py
@@ -15,10 +15,19 @@ def __init__(
**kwargs,
):
self.agent = agent
+
+ # Avoid passing duplicate `description` to the FunctionTool dataclass.
+ # Some call sites (e.g. SubAgentOrchestrator) pass `description` via kwargs
+ # to override what the main agent sees, while we also compute a default
+ # description here.
+ description = kwargs.pop(
+ "description",
+ agent.instructions or self.default_description(agent.name),
+ )
super().__init__(
name=f"transfer_to_{agent.name}",
parameters=parameters or self.default_parameters(),
- description=agent.instructions or self.default_description(agent.name),
+ description=description,
**kwargs,
)
diff --git a/astrbot/core/subagent_orchestrator.py b/astrbot/core/subagent_orchestrator.py
index f160672221..d565aa7c54 100644
--- a/astrbot/core/subagent_orchestrator.py
+++ b/astrbot/core/subagent_orchestrator.py
@@ -15,7 +15,10 @@ class SubAgentConfig:
"""Runtime representation of a configured subagent."""
name: str
+ # Instructions are used as the subagent's system prompt.
instructions: str
+ # Public description is what the main LLM sees for transfer_to_* tool description.
+ public_description: str
tools: list[str]
enabled: bool = True
@@ -63,7 +66,8 @@ def reload_from_config(self, provider_settings: dict[str, Any]) -> None:
if not name:
continue
- instructions = str(item.get("description", "")).strip()
+ instructions = str(item.get("system_prompt", "")).strip()
+ public_description = str(item.get("public_description", "")).strip()
tools = item.get("tools", [])
if not isinstance(tools, list):
tools = []
@@ -74,7 +78,9 @@ def reload_from_config(self, provider_settings: dict[str, Any]) -> None:
instructions=instructions,
tools=tools,
)
- handoff = HandoffTool(agent=agent)
+ # The tool description should be a short description for the main LLM,
+ # while the subagent system prompt can be longer/more specific.
+ handoff = HandoffTool(agent=agent, description=public_description or None)
# Mark as dynamic so we can replace/remove later.
handoff.handler_module_path = "core.subagent_orchestrator"
diff --git a/astrbot/dashboard/routes/subagent.py b/astrbot/dashboard/routes/subagent.py
index 08bbb8ddad..24cb2fceff 100644
--- a/astrbot/dashboard/routes/subagent.py
+++ b/astrbot/dashboard/routes/subagent.py
@@ -1,6 +1,7 @@
import traceback
from quart import request
+from quart import jsonify
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -16,11 +17,13 @@ def __init__(
) -> None:
super().__init__(context)
self.core_lifecycle = core_lifecycle
- self.routes = {
- "/subagent/config": ("GET", self.get_config),
- "/subagent/config": ("POST", self.update_config),
- "/subagent/available-tools": ("GET", self.get_available_tools),
- }
+ # NOTE: dict cannot hold duplicate keys; use list form to register multiple
+ # methods for the same path.
+ self.routes = [
+ ("/subagent/config", ("GET", self.get_config)),
+ ("/subagent/config", ("POST", self.update_config)),
+ ("/subagent/available-tools", ("GET", self.get_available_tools)),
+ ]
self.register_routes()
async def get_config(self):
@@ -45,16 +48,16 @@ async def get_config(self):
data.setdefault("main_enable", False)
data.setdefault("main_tools_policy", "handoff_only")
data.setdefault("agents", [])
- return Response().ok(data=data).__dict__
+ return jsonify(Response().ok(data=data).__dict__)
except Exception as e:
logger.error(traceback.format_exc())
- return Response().error(f"获取 subagent 配置失败: {e!s}").__dict__
+ return jsonify(Response().error(f"获取 subagent 配置失败: {e!s}").__dict__)
async def update_config(self):
try:
data = await request.json
if not isinstance(data, dict):
- return Response().error("配置必须为 JSON 对象").__dict__
+ return jsonify(Response().error("配置必须为 JSON 对象").__dict__)
cfg = self.core_lifecycle.astrbot_config
provider_settings = cfg.get("provider_settings", {})
@@ -62,17 +65,18 @@ async def update_config(self):
cfg["provider_settings"] = provider_settings
# Persist to cmd_config.json
- self.core_lifecycle.astrbot_config_mgr.save(cfg)
+ # AstrBotConfigManager does not expose a `save()` method; persist via AstrBotConfig.
+ cfg.save_config()
# Reload dynamic handoff tools if orchestrator exists
orch = getattr(self.core_lifecycle, "subagent_orchestrator", None)
if orch is not None:
orch.reload_from_config(provider_settings)
- return Response().ok(message="保存成功").__dict__
+ return jsonify(Response().ok(message="保存成功").__dict__)
except Exception as e:
logger.error(traceback.format_exc())
- return Response().error(f"保存 subagent 配置失败: {e!s}").__dict__
+ return jsonify(Response().error(f"保存 subagent 配置失败: {e!s}").__dict__)
async def get_available_tools(self):
"""Return all registered tools (name/description/parameters/active/origin).
@@ -92,7 +96,7 @@ async def get_available_tools(self):
"handler_module_path": tool.handler_module_path,
}
)
- return Response().ok(data=tools_dict).__dict__
+ return jsonify(Response().ok(data=tools_dict).__dict__)
except Exception as e:
logger.error(traceback.format_exc())
- return Response().error(f"获取可用工具失败: {e!s}").__dict__
+ return jsonify(Response().error(f"获取可用工具失败: {e!s}").__dict__)
diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py
index 5b5abdfeb0..6f1386d092 100644
--- a/astrbot/dashboard/server.py
+++ b/astrbot/dashboard/server.py
@@ -132,7 +132,8 @@ async def auth_middleware(self):
r = jsonify(Response().error("未授权").__dict__)
r.status_code = 401
return r
- token = token.removeprefix("Bearer ")
+ # Be tolerant of different header casing / formatting.
+ token = token.strip().removeprefix("Bearer ").strip()
try:
payload = jwt.decode(token, self._jwt_secret, algorithms=["HS256"])
g.username = payload["username"]
diff --git a/dashboard/src/main.ts b/dashboard/src/main.ts
index 958eded222..305c7644b6 100644
--- a/dashboard/src/main.ts
+++ b/dashboard/src/main.ts
@@ -61,6 +61,20 @@ axios.interceptors.request.use((config) => {
return config;
});
+// Keep fetch() calls consistent with axios by automatically attaching the JWT.
+// Some parts of the UI use fetch directly; without this, those requests will 401.
+const _origFetch = window.fetch.bind(window);
+window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
+ const token = localStorage.getItem('token');
+ if (!token) return _origFetch(input, init);
+
+ const headers = new Headers(init?.headers || (typeof input !== 'string' && 'headers' in input ? (input as Request).headers : undefined));
+ if (!headers.has('Authorization')) {
+ headers.set('Authorization', `Bearer ${token}`);
+ }
+ return _origFetch(input, { ...init, headers });
+};
+
loader.config({
paths: {
vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/vs',
diff --git a/dashboard/src/views/SubAgentPage.vue b/dashboard/src/views/SubAgentPage.vue
index 625fcb0ea3..359828c572 100644
--- a/dashboard/src/views/SubAgentPage.vue
+++ b/dashboard/src/views/SubAgentPage.vue
@@ -116,9 +116,12 @@
label="分配工具(多选)"
variant="outlined"
density="comfortable"
+ class="subagent-tools"
multiple
chips
closable-chips
+ :menu-props="{ maxHeight: 320 }"
+ :max-chips="8"
:loading="toolsLoading"
:disabled="toolsLoading"
clearable
@@ -127,15 +130,26 @@
+
+
预览:主 LLM 将看到的 handoff 工具
@@ -175,7 +189,8 @@ type ToolOption = { title: string; value: string }
type SubAgentItem = {
__key: string
name: string
- description: string
+ public_description: string
+ system_prompt: string
tools: string[]
enabled: boolean
}
@@ -221,14 +236,16 @@ function normalizeConfig(raw: any): SubAgentConfig {
const agents: SubAgentItem[] = agentsRaw.map((a: any, i: number) => {
const name = (a?.name ?? '').toString()
- const description = (a?.description ?? '').toString()
+ const public_description = (a?.public_description ?? '').toString()
+ const system_prompt = (a?.system_prompt ?? '').toString()
const tools = Array.isArray(a?.tools) ? a.tools.map((x: any) => String(x)) : []
const enabled = a?.enabled !== false
return {
__key: `${Date.now()}_${i}_${Math.random().toString(16).slice(2)}`,
name,
- description,
+ public_description,
+ system_prompt,
tools,
enabled
}
@@ -296,7 +313,8 @@ function addAgent() {
cfg.value.agents.push({
__key: `${Date.now()}_${Math.random().toString(16).slice(2)}`,
name: '',
- description: '',
+ public_description: '',
+ system_prompt: '',
tools: [],
enabled: true
})
@@ -316,7 +334,8 @@ async function save() {
main_tools_policy: 'handoff_only',
agents: cfg.value.agents.map(a => ({
name: a.name,
- description: a.description,
+ public_description: a.public_description,
+ system_prompt: a.system_prompt,
tools: a.tools,
enabled: a.enabled
}))
@@ -351,3 +370,25 @@ onMounted(() => {
padding-bottom: 40px;
}
+
+
From 3cf0880f9896975a9723cb1e91c0e2095ed39da4 Mon Sep 17 00:00:00 2001
From: advent259141 <2968474907@qq.com>
Date: Mon, 26 Jan 2026 22:14:56 +0800
Subject: [PATCH 03/24] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dbug=EF=BC=8C=E4=BC=98?=
=?UTF-8?q?=E5=8C=96=E5=89=8D=E7=AB=AF=E9=A1=B5=E9=9D=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
astrbot/core/agent/handoff.py | 4 +
astrbot/core/astr_agent_tool_exec.py | 5 +-
astrbot/core/subagent_orchestrator.py | 6 ++
astrbot/dashboard/routes/subagent.py | 7 ++
dashboard/src/views/SubAgentPage.vue | 143 +++++++++++++++++++++-----
5 files changed, 137 insertions(+), 28 deletions(-)
diff --git a/astrbot/core/agent/handoff.py b/astrbot/core/agent/handoff.py
index 511fb53995..0e2d934357 100644
--- a/astrbot/core/agent/handoff.py
+++ b/astrbot/core/agent/handoff.py
@@ -31,6 +31,10 @@ def __init__(
**kwargs,
)
+ # Optional provider override for this subagent. When set, the handoff
+ # execution will use this chat provider id instead of the global/default.
+ self.provider_id: str | None = None
+
def default_parameters(self) -> dict:
return {
"type": "object",
diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py
index 5d40f48fac..83c3512c28 100644
--- a/astrbot/core/astr_agent_tool_exec.py
+++ b/astrbot/core/astr_agent_tool_exec.py
@@ -74,7 +74,10 @@ async def _execute_handoff(
ctx = run_context.context.context
event = run_context.context.event
umo = event.unified_msg_origin
- prov_id = await ctx.get_current_chat_provider_id(umo)
+
+ # Use per-subagent provider override if configured; otherwise fall back
+ # to the current/default provider resolution.
+ prov_id = getattr(tool, "provider_id", None) or await ctx.get_current_chat_provider_id(umo)
llm_resp = await ctx.tool_loop_agent(
event=event,
chat_provider_id=prov_id,
diff --git a/astrbot/core/subagent_orchestrator.py b/astrbot/core/subagent_orchestrator.py
index d565aa7c54..38f3841458 100644
--- a/astrbot/core/subagent_orchestrator.py
+++ b/astrbot/core/subagent_orchestrator.py
@@ -68,6 +68,9 @@ def reload_from_config(self, provider_settings: dict[str, Any]) -> None:
instructions = str(item.get("system_prompt", "")).strip()
public_description = str(item.get("public_description", "")).strip()
+ provider_id = item.get("provider_id")
+ if provider_id is not None:
+ provider_id = str(provider_id).strip() or None
tools = item.get("tools", [])
if not isinstance(tools, list):
tools = []
@@ -82,6 +85,9 @@ def reload_from_config(self, provider_settings: dict[str, Any]) -> None:
# while the subagent system prompt can be longer/more specific.
handoff = HandoffTool(agent=agent, description=public_description or None)
+ # Optional per-subagent chat provider override.
+ handoff.provider_id = provider_id
+
# Mark as dynamic so we can replace/remove later.
handoff.handler_module_path = "core.subagent_orchestrator"
diff --git a/astrbot/dashboard/routes/subagent.py b/astrbot/dashboard/routes/subagent.py
index 24cb2fceff..34b31c9e82 100644
--- a/astrbot/dashboard/routes/subagent.py
+++ b/astrbot/dashboard/routes/subagent.py
@@ -48,6 +48,13 @@ async def get_config(self):
data.setdefault("main_enable", False)
data.setdefault("main_tools_policy", "handoff_only")
data.setdefault("agents", [])
+
+ # Backward/forward compatibility: ensure each agent contains provider_id.
+ # None means follow global/default provider settings.
+ if isinstance(data.get("agents"), list):
+ for a in data["agents"]:
+ if isinstance(a, dict):
+ a.setdefault("provider_id", None)
return jsonify(Response().ok(data=data).__dict__)
except Exception as e:
logger.error(traceback.format_exc())
diff --git a/dashboard/src/views/SubAgentPage.vue b/dashboard/src/views/SubAgentPage.vue
index 359828c572..632194885f 100644
--- a/dashboard/src/views/SubAgentPage.vue
+++ b/dashboard/src/views/SubAgentPage.vue
@@ -29,18 +29,14 @@
- 启用后:主 LLM 只会看到 transfer_to_*,不会直接注入/调用其他工具;所有工具调用交给 SubAgent 完成。
- 关闭后:恢复原有行为(按 persona 选择并直接注入工具)。
+
+ 启用:主 LLM 仅负责对话与“转交”,只会看到 transfer_to_* 这类委派工具;需要调用工具时,会把任务交给对应 SubAgent 执行。SubAgent 负责真正的工具调用与结果整理,并把结论回传给主 LLM。
+
+
+ 关闭:恢复原有行为(按 persona 选择并直接注入工具)。
+
-
- Router Prompt 当前使用系统内置默认值,暂不支持在 WebUI 中自定义。
-
-
SubAgents
-
-
+
+
{{ agent.enabled ? '启用' : '停用' }}
-
- {{ agent.name || '未命名 SubAgent' }}
+
+
+
{{ agent.name || '未命名 SubAgent' }}
+
transfer_to_{{ agent.name || '...' }}
-
+
+
+ 启用
+
+
-
-
+
+
-
-
+
-
+
import { onMounted, ref } from 'vue'
import axios from 'axios'
+import ProviderSelector from '@/components/shared/ProviderSelector.vue'
type ToolOption = { title: string; value: string }
@@ -193,6 +205,7 @@ type SubAgentItem = {
system_prompt: string
tools: string[]
enabled: boolean
+ provider_id?: string
}
type SubAgentConfig = {
@@ -240,6 +253,7 @@ function normalizeConfig(raw: any): SubAgentConfig {
const system_prompt = (a?.system_prompt ?? '').toString()
const tools = Array.isArray(a?.tools) ? a.tools.map((x: any) => String(x)) : []
const enabled = a?.enabled !== false
+ const provider_id = (a?.provider_id ?? undefined) as (string | undefined)
return {
__key: `${Date.now()}_${i}_${Math.random().toString(16).slice(2)}`,
@@ -248,6 +262,8 @@ function normalizeConfig(raw: any): SubAgentConfig {
system_prompt,
tools,
enabled
+ ,
+ provider_id
}
})
@@ -316,7 +332,8 @@ function addAgent() {
public_description: '',
system_prompt: '',
tools: [],
- enabled: true
+ enabled: true,
+ provider_id: undefined
})
}
@@ -337,7 +354,8 @@ async function save() {
public_description: a.public_description,
system_prompt: a.system_prompt,
tools: a.tools,
- enabled: a.enabled
+ enabled: a.enabled,
+ provider_id: a.provider_id
}))
}
@@ -369,6 +387,77 @@ onMounted(() => {
padding-top: 8px;
padding-bottom: 40px;
}
+
+.subagent-panel-title {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.subagent-title-left {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.subagent-title-text {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.subagent-title-name {
+ font-weight: 600;
+ line-height: 1.2;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 520px;
+}
+
+.subagent-title-sub {
+ font-size: 12px;
+ opacity: 0.72;
+ line-height: 1.2;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 520px;
+}
+
+.subagent-title-right {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.subagent-actions {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+}
+
+.subagent-provider {
+ flex: 1;
+ min-width: 260px;
+}
+
+.subagent-enabled-inline {
+ margin-right: 2px;
+}
+
+/* Keep the switch compact inside the expansion-panel title row. */
+.subagent-enabled-inline :deep(.v-input__details) {
+ display: none;
+}
+
+.subagent-enabled-inline :deep(.v-selection-control) {
+ min-height: 32px;
+}
diff --git a/dashboard/src/views/SubAgentPage.vue b/dashboard/src/views/SubAgentPage.vue
index e0756e35eb..5e407dfdf4 100644
--- a/dashboard/src/views/SubAgentPage.vue
+++ b/dashboard/src/views/SubAgentPage.vue
@@ -124,85 +124,43 @@
class="subagent-provider"
/>
-
+
-
-
+
-
-
- 已分配:{{ (agent.tools || []).length }} 个工具
-
-
-
-
-
预览:主 LLM 将看到的 handoff 工具
transfer_to_{{ agent.name || '...' }}
-
- {{ t }}
+
+ Persona: {{ agent.persona_id }}
@@ -224,25 +182,13 @@ import { onMounted, ref } from 'vue'
import axios from 'axios'
import ProviderSelector from '@/components/shared/ProviderSelector.vue'
-type ToolOption = { title: string; value: string }
-
-type ToolGroup = {
- key: string
- label: string
- options: ToolOption[]
-}
-
type SubAgentItem = {
__key: string
name: string
+ persona_id: string
public_description: string
- system_prompt: string
- tools: string[]
enabled: boolean
provider_id?: string
- // UI-only: current tool group selection state
- __tool_group?: string
- __tool_group_selected?: string[]
}
type MainMode = 'disabled' | 'unassigned_to_main' | 'handoff_only'
@@ -254,7 +200,6 @@ type SubAgentConfig = {
const loading = ref(false)
const saving = ref(false)
-const toolsLoading = ref(false)
const snackbar = ref({
show: false,
@@ -277,59 +222,8 @@ const cfg = ref({
agents: []
})
-
-const toolGroups = ref([])
-const toolGroupOptions = ref<{ title: string; value: string }[]>([])
-
-function modulePathToLabel(mp: unknown): string {
- const raw = (mp ?? '').toString().trim()
- if (!raw) return '其他/未归类'
- // Typical module paths look like:
- // - data.plugins..main
- // - astrbot.builtin_stars..main
- // - astrbot.plugins..main
- // We strip common prefixes and the trailing ".main" for display.
- const trimmed = raw.replace(/\.main$/, '')
- if (trimmed.startsWith('data.plugins.')) return trimmed.replace(/^data\.plugins\./, '')
- if (trimmed.startsWith('astrbot.builtin_stars.')) return `builtin: ${trimmed.replace(/^astrbot\.builtin_stars\./, '')}`
- if (trimmed.startsWith('astrbot.plugins.')) return trimmed.replace(/^astrbot\.plugins\./, '')
- if (raw.startsWith('plugins.')) return raw.replace(/^plugins\./, '')
- if (raw.startsWith('builtin_stars.')) return `builtin: ${raw.replace(/^builtin_stars\./, '')}`
- if (raw.startsWith('core.')) return `core: ${raw.replace(/^core\./, '')}`
- return raw
-}
-
-function rebuildToolGroupOptions() {
- toolGroupOptions.value = toolGroups.value.map(g => ({ title: g.label, value: g.key }))
-}
-
-function getToolOptionsByGroup(groupKey: string | undefined): ToolOption[] {
- if (!groupKey) return []
- return toolGroups.value.find(g => g.key === groupKey)?.options ?? []
-}
-
-function onGroupChanged(agent: SubAgentItem) {
- // When switching groups, reflect already-assigned tools for that group.
- const groupOptions = getToolOptionsByGroup(agent.__tool_group)
- const allowed = new Set(groupOptions.map(o => o.value))
- agent.__tool_group_selected = (agent.tools || []).filter(t => allowed.has(t))
-}
-
-function syncGroupSelectionToAgentTools(agent: SubAgentItem) {
- const groupOptions = getToolOptionsByGroup(agent.__tool_group)
- const allowed = new Set(groupOptions.map(o => o.value))
-
- const selected = Array.isArray(agent.__tool_group_selected)
- ? agent.__tool_group_selected
- : []
-
- // Replace only tools belonging to this group; keep tools from other groups intact.
- const kept = (agent.tools || []).filter(t => !allowed.has(t))
- const merged = [...kept, ...selected.filter(t => allowed.has(t))]
-
- const seen = new Set()
- agent.tools = merged.filter(t => (seen.has(t) ? false : (seen.add(t), true)))
-}
+const personaOptions = ref<{ title: string; value: string }[]>([])
+const personaLoading = ref(false)
function normalizeConfig(raw: any): SubAgentConfig {
const main_enable = !!raw?.main_enable
@@ -341,23 +235,19 @@ function normalizeConfig(raw: any): SubAgentConfig {
const agents: SubAgentItem[] = agentsRaw.map((a: any, i: number) => {
const name = (a?.name ?? '').toString()
+ const persona_id = (a?.persona_id ?? '').toString()
const public_description = (a?.public_description ?? '').toString()
- const system_prompt = (a?.system_prompt ?? '').toString()
- const tools = Array.isArray(a?.tools) ? a.tools.map((x: any) => String(x)) : []
const enabled = a?.enabled !== false
const provider_id = (a?.provider_id ?? undefined) as (string | undefined)
return {
__key: `${Date.now()}_${i}_${Math.random().toString(16).slice(2)}`,
name,
+ persona_id,
public_description,
- system_prompt,
- tools,
enabled
,
- provider_id,
- __tool_group: undefined,
- __tool_group_selected: []
+ provider_id
}
})
@@ -380,66 +270,21 @@ async function loadConfig() {
}
}
-async function loadTools() {
- toolsLoading.value = true
+async function loadPersonas() {
+ personaLoading.value = true
try {
- // Prefer our dedicated endpoint (includes handler_module_path)
- const res = await axios.get('/api/subagent/available-tools')
+ const res = await axios.get('/api/persona/list')
if (res.data.status === 'ok') {
const list = Array.isArray(res.data.data) ? res.data.data : []
- const groups = new Map()
- for (const t of list) {
- if (!t?.name) continue
- const name = String(t.name)
- const desc = (t.description ?? '').toString().trim()
- const mp = (t.handler_module_path ?? '').toString()
- const key = mp || '__other__'
- const options = groups.get(key) ?? []
- options.push({ title: desc ? `${name} — ${desc}` : name, value: name })
- groups.set(key, options)
- }
-
- toolGroups.value = Array.from(groups.entries())
- .map(([key, options]) => ({
- key,
- label: modulePathToLabel(key === '__other__' ? '' : key),
- options: options.sort((a, b) => a.value.localeCompare(b.value))
- }))
- .sort((a, b) => a.label.localeCompare(b.label))
-
- rebuildToolGroupOptions()
- } else {
- toast(res.data.message || '获取工具列表失败', 'error')
- }
- } catch {
- // Fallback to existing tools list endpoint
- try {
- const res2 = await axios.get('/api/tools/list')
- if (res2.data.status === 'ok') {
- const list = Array.isArray(res2.data.data) ? res2.data.data : []
- const options = list
- .filter((t: any) => !!t?.name)
- .map((t: any) => {
- const name = String(t.name)
- const desc = (t.description ?? '').toString().trim()
- return { title: desc ? `${name} — ${desc}` : name, value: name }
- })
- .sort((a: ToolOption, b: ToolOption) => a.value.localeCompare(b.value))
-
- toolGroups.value = [
- {
- key: '__all__',
- label: '全部工具',
- options
- }
- ]
- rebuildToolGroupOptions()
- }
- } catch {
- toast('获取工具列表失败', 'error')
+ personaOptions.value = list.map((p: any) => ({
+ title: p.persona_id,
+ value: p.persona_id
+ }))
}
+ } catch (e: any) {
+ toast(e?.response?.data?.message || '获取 Persona 列表失败', 'error')
} finally {
- toolsLoading.value = false
+ personaLoading.value = false
}
}
@@ -447,13 +292,10 @@ function addAgent() {
cfg.value.agents.push({
__key: `${Date.now()}_${Math.random().toString(16).slice(2)}`,
name: '',
+ persona_id: '',
public_description: '',
- system_prompt: '',
- tools: [],
enabled: true,
- provider_id: undefined,
- __tool_group: undefined,
- __tool_group_selected: []
+ provider_id: undefined
})
}
@@ -479,6 +321,10 @@ function validateBeforeSave(): boolean {
return false
}
seen.add(name)
+ if (!a.persona_id) {
+ toast(`SubAgent ${name} 未选择 Persona`, 'warning')
+ return false
+ }
}
return true
}
@@ -494,9 +340,8 @@ async function save() {
main_tools_policy: mode,
agents: cfg.value.agents.map(a => ({
name: a.name,
+ persona_id: a.persona_id,
public_description: a.public_description,
- system_prompt: a.system_prompt,
- tools: a.tools,
enabled: a.enabled,
provider_id: a.provider_id
}))
@@ -516,13 +361,7 @@ async function save() {
}
async function reload() {
- await Promise.all([loadConfig(), loadTools()])
-
- // Initialize UI-only selections after tools load.
- for (const a of cfg.value.agents) {
- if (!a.__tool_group) a.__tool_group = undefined
- if (!Array.isArray(a.__tool_group_selected)) a.__tool_group_selected = []
- }
+ await Promise.all([loadConfig(), loadPersonas()])
}
onMounted(() => {
From 0c5308a1324ad00904b04019ba57040360a2bb89 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sun, 1 Feb 2026 00:43:41 +0800
Subject: [PATCH 10/24] refactor: extract main agent
---
Makefile | 32 +
.../astrbot/process_llm_request.py | 2 +-
astrbot/core/astr_agent_tool_exec.py | 46 +-
astrbot/core/astr_main_agent.py | 545 +++++++++++++
.../utils.py => astr_main_agent_resources.py} | 4 +-
astrbot/core/core_lifecycle.py | 4 +-
astrbot/core/cron/manager.py | 59 +-
.../method/agent_sub_stages/internal.py | 723 +++---------------
astrbot/core/provider/entities.py | 2 +-
9 files changed, 781 insertions(+), 636 deletions(-)
create mode 100644 Makefile
create mode 100644 astrbot/core/astr_main_agent.py
rename astrbot/core/{pipeline/process_stage/utils.py => astr_main_agent_resources.py} (98%)
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000000..d8fdb04baf
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,32 @@
+.PHONY: worktree worktree-add worktree-rm
+
+WORKTREE_DIR ?= ../astrbot_worktree
+BRANCH ?= $(word 2,$(MAKECMDGOALS))
+BASE ?= $(word 3,$(MAKECMDGOALS))
+BASE ?= master
+
+worktree:
+ @echo "Usage:"
+ @echo " make worktree-add [base-branch]"
+ @echo " make worktree-rm "
+
+worktree-add:
+ifeq ($(strip $(BRANCH)),)
+ $(error Branch name required. Usage: make worktree-add [base-branch])
+endif
+ @mkdir -p $(WORKTREE_DIR)
+ git worktree add $(WORKTREE_DIR)/$(BRANCH) -b $(BRANCH) $(BASE)
+
+worktree-rm:
+ifeq ($(strip $(BRANCH)),)
+ $(error Branch name required. Usage: make worktree-rm )
+endif
+ @if [ -d "$(WORKTREE_DIR)/$(BRANCH)" ]; then \
+ git worktree remove $(WORKTREE_DIR)/$(BRANCH); \
+ else \
+ echo "Worktree $(WORKTREE_DIR)/$(BRANCH) not found."; \
+ fi
+
+# Swallow extra args (branch/base) so make doesn't treat them as targets
+%:
+ @true
diff --git a/astrbot/builtin_stars/astrbot/process_llm_request.py b/astrbot/builtin_stars/astrbot/process_llm_request.py
index fb8639c656..06bfe790ad 100644
--- a/astrbot/builtin_stars/astrbot/process_llm_request.py
+++ b/astrbot/builtin_stars/astrbot/process_llm_request.py
@@ -9,7 +9,7 @@
from astrbot.api.provider import Provider, ProviderRequest
from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.message import TextPart
-from astrbot.core.pipeline.process_stage.utils import (
+from astrbot.core.astr_main_agent_resources import (
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT,
LOCAL_EXECUTE_SHELL_TOOL,
LOCAL_PYTHON_TOOL,
diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py
index 2a50a00bba..91f8b3e216 100644
--- a/astrbot/core/astr_agent_tool_exec.py
+++ b/astrbot/core/astr_agent_tool_exec.py
@@ -22,6 +22,7 @@
)
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.provider.register import llm_tools
+from astrbot.core.message.components import Plain
class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
@@ -147,6 +148,8 @@ async def _execute_background(
task_id: str,
**tool_args,
):
+ from astrbot.core.astr_main_agent import build_main_agent, MainAgentBuildConfig
+
# run the tool
result_text = ""
try:
@@ -187,7 +190,48 @@ async def _execute_background(
extras=extras,
message_type=session.message_type,
)
- ctx.get_event_queue().put_nowait(cron_event)
+ config = MainAgentBuildConfig(tool_call_timeout=3600)
+ result = await build_main_agent(
+ event=cron_event, plugin_context=ctx, config=config
+ )
+ if not result:
+ logger.error("Failed to build main agent for cron job.")
+ return
+ runner = result.agent_runner
+ req = result.provider_request
+
+ bg = extras["background_task_result"]
+ result_text = bg["result"] or "Empty Response"
+ if req.contexts:
+ context_dump = req._print_friendly_context()
+ req.system_prompt += (
+ "\n\nBellow is you and user previous conversation history:\n"
+ f"{context_dump}"
+ )
+ req.system_prompt += (
+ "You now have a new background task result:\n"
+ f"- Task ID: {bg['task_id']}\n"
+ f"- Executed Tool: {tool.name}\n"
+ f"- Tool Args: {tool_args}\n"
+ f"- Result: {result_text}\n"
+ f"- Note: {note}\n"
+ "Please tell the user the result of the background task in your next response."
+ )
+
+ req.prompt = (
+ "You have a new background task result to report to the user."
+ " Please include the result in your next response."
+ " Using same language as previous conversation."
+ )
+
+ async for _ in runner.step_until_done(30):
+ pass
+ llm_resp = runner.get_final_llm_resp()
+ if not llm_resp:
+ logger.warning("Cron job agent got no response")
+ return
+ message_chain = MessageChain(chain=[Plain(text=llm_resp.completion_text)])
+ await ctx.send_message(session=session, message_chain=message_chain)
@classmethod
async def _execute_local(
diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
new file mode 100644
index 0000000000..b35bd971b4
--- /dev/null
+++ b/astrbot/core/astr_main_agent.py
@@ -0,0 +1,545 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+from dataclasses import dataclass, field
+
+from astrbot.core import logger
+from astrbot.core.agent.message import TextPart
+from astrbot.core.agent.tool import ToolSet
+from astrbot.core.astr_agent_context import AgentContextWrapper, AstrAgentContext
+from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS
+from astrbot.core.astr_agent_run_util import AgentRunner
+from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
+from astrbot.core.conversation_mgr import Conversation
+from astrbot.core.message.components import File, Image, Reply
+from astrbot.core.platform.astr_message_event import AstrMessageEvent
+from astrbot.core.provider import Provider
+from astrbot.core.provider.entities import ProviderRequest
+from astrbot.core.star.context import Context
+from astrbot.core.star.star_handler import star_map
+from astrbot.core.tools.cron_tools import (
+ CREATE_CRON_JOB_TOOL,
+ DELETE_CRON_JOB_TOOL,
+ LIST_CRON_JOBS_TOOL,
+)
+from astrbot.core.utils.file_extract import extract_file_moonshotai
+from astrbot.core.utils.llm_metadata import LLM_METADATAS
+
+from .astr_main_agent_resources import (
+ CHATUI_EXTRA_PROMPT,
+ EXECUTE_SHELL_TOOL,
+ FILE_DOWNLOAD_TOOL,
+ FILE_UPLOAD_TOOL,
+ KNOWLEDGE_BASE_QUERY_TOOL,
+ LIVE_MODE_SYSTEM_PROMPT,
+ LLM_SAFETY_MODE_SYSTEM_PROMPT,
+ PYTHON_TOOL,
+ SANDBOX_MODE_PROMPT,
+ TOOL_CALL_PROMPT,
+ TOOL_CALL_PROMPT_SKILLS_LIKE_MODE,
+ retrieve_knowledge_base,
+)
+
+
+@dataclass(slots=True)
+class MainAgentBuildConfig:
+ tool_call_timeout: int
+ tool_schema_mode: str = "full"
+ provider_wake_prefix: str = ""
+ streaming_response: bool = True
+ sanitize_context_by_modalities: bool = False
+ kb_agentic_mode: bool = False
+ file_extract_enabled: bool = False
+ file_extract_prov: str = "moonshotai"
+ file_extract_msh_api_key: str = ""
+ context_limit_reached_strategy: str = "truncate_by_turns"
+ llm_compress_instruction: str = ""
+ llm_compress_keep_recent: int = 4
+ llm_compress_provider_id: str = ""
+ max_context_length: int = 0
+ dequeue_context_length: int = 1
+ llm_safety_mode: bool = True
+ safety_mode_strategy: str = "system_prompt"
+ sandbox_cfg: dict = field(default_factory=dict)
+
+
+@dataclass(slots=True)
+class MainAgentBuildResult:
+ agent_runner: AgentRunner
+ provider_request: ProviderRequest
+ provider: Provider
+
+
+def _select_provider(
+ event: AstrMessageEvent, plugin_context: Context
+) -> Provider | None:
+ """Select chat provider for the event."""
+ sel_provider = event.get_extra("selected_provider")
+ if sel_provider and isinstance(sel_provider, str):
+ provider = plugin_context.get_provider_by_id(sel_provider)
+ if not provider:
+ logger.error("未找到指定的提供商: %s。", sel_provider)
+ if not isinstance(provider, Provider):
+ logger.error(
+ "选择的提供商类型无效(%s),跳过 LLM 请求处理。", type(provider)
+ )
+ return None
+ return provider
+ try:
+ return plugin_context.get_using_provider(umo=event.unified_msg_origin)
+ except ValueError as exc:
+ logger.error("Error occurred while selecting provider: %s", exc)
+ return None
+
+
+async def _get_session_conv(
+ event: AstrMessageEvent, plugin_context: Context
+) -> Conversation:
+ conv_mgr = plugin_context.conversation_manager
+ umo = event.unified_msg_origin
+ cid = await conv_mgr.get_curr_conversation_id(umo)
+ if not cid:
+ cid = await conv_mgr.new_conversation(umo, event.get_platform_id())
+ conversation = await conv_mgr.get_conversation(umo, cid)
+ if not conversation:
+ cid = await conv_mgr.new_conversation(umo, event.get_platform_id())
+ conversation = await conv_mgr.get_conversation(umo, cid)
+ if not conversation:
+ raise RuntimeError("无法创建新的对话。")
+ return conversation
+
+
+async def _apply_kb(
+ event: AstrMessageEvent,
+ req: ProviderRequest,
+ plugin_context: Context,
+ config: MainAgentBuildConfig,
+) -> None:
+ if not config.kb_agentic_mode:
+ if req.prompt is None:
+ return
+ try:
+ kb_result = await retrieve_knowledge_base(
+ query=req.prompt,
+ umo=event.unified_msg_origin,
+ context=plugin_context,
+ )
+ if not kb_result:
+ return
+ if req.system_prompt is not None:
+ req.system_prompt += (
+ f"\n\n[Related Knowledge Base Results]:\n{kb_result}"
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.error("Error occurred while retrieving knowledge base: %s", exc)
+ else:
+ if req.func_tool is None:
+ req.func_tool = ToolSet()
+ req.func_tool.add_tool(KNOWLEDGE_BASE_QUERY_TOOL)
+
+
+async def _apply_file_extract(
+ event: AstrMessageEvent,
+ req: ProviderRequest,
+ config: MainAgentBuildConfig,
+) -> None:
+ file_paths = []
+ file_names = []
+ for comp in event.message_obj.message:
+ if isinstance(comp, File):
+ file_paths.append(await comp.get_file())
+ file_names.append(comp.name)
+ elif isinstance(comp, Reply) and comp.chain:
+ for reply_comp in comp.chain:
+ if isinstance(reply_comp, File):
+ file_paths.append(await reply_comp.get_file())
+ file_names.append(reply_comp.name)
+ if not file_paths:
+ return
+ if not req.prompt:
+ req.prompt = "总结一下文件里面讲了什么?"
+ if config.file_extract_prov == "moonshotai":
+ if not config.file_extract_msh_api_key:
+ logger.error("Moonshot AI API key for file extract is not set")
+ return
+ file_contents = await asyncio.gather(
+ *[
+ extract_file_moonshotai(
+ file_path,
+ config.file_extract_msh_api_key,
+ )
+ for file_path in file_paths
+ ]
+ )
+ else:
+ logger.error("Unsupported file extract provider: %s", config.file_extract_prov)
+ return
+
+ for file_content, file_name in zip(file_contents, file_names):
+ req.contexts.append(
+ {
+ "role": "system",
+ "content": (
+ "File Extract Results of user uploaded files:\n"
+ f"{file_content}\nFile Name: {file_name or 'Unknown'}"
+ ),
+ },
+ )
+
+
+def _modalities_fix(provider: Provider, req: ProviderRequest) -> None:
+ if req.image_urls:
+ provider_cfg = provider.provider_config.get("modalities", ["image"])
+ if "image" not in provider_cfg:
+ logger.debug(
+ "Provider %s does not support image, using placeholder.", provider
+ )
+ image_count = len(req.image_urls)
+ placeholder = " ".join(["[图片]"] * image_count)
+ if req.prompt:
+ req.prompt = f"{placeholder} {req.prompt}"
+ else:
+ req.prompt = placeholder
+ req.image_urls = []
+ if req.func_tool:
+ provider_cfg = provider.provider_config.get("modalities", ["tool_use"])
+ if "tool_use" not in provider_cfg:
+ logger.debug(
+ "Provider %s does not support tool_use, clearing tools.", provider
+ )
+ req.func_tool = None
+
+
+def _sanitize_context_by_modalities(
+ config: MainAgentBuildConfig,
+ provider: Provider,
+ req: ProviderRequest,
+) -> None:
+ if not config.sanitize_context_by_modalities:
+ return
+ if not isinstance(req.contexts, list) or not req.contexts:
+ return
+ modalities = provider.provider_config.get("modalities", None)
+ if not modalities or not isinstance(modalities, list):
+ return
+ supports_image = bool("image" in modalities)
+ supports_tool_use = bool("tool_use" in modalities)
+ if supports_image and supports_tool_use:
+ return
+
+ sanitized_contexts: list[dict] = []
+ removed_image_blocks = 0
+ removed_tool_messages = 0
+ removed_tool_calls = 0
+
+ for msg in req.contexts:
+ if not isinstance(msg, dict):
+ continue
+ role = msg.get("role")
+ if not role:
+ continue
+
+ new_msg = msg
+ if not supports_tool_use:
+ if role == "tool":
+ removed_tool_messages += 1
+ continue
+ if role == "assistant" and "tool_calls" in new_msg:
+ if "tool_calls" in new_msg:
+ removed_tool_calls += 1
+ new_msg.pop("tool_calls", None)
+ new_msg.pop("tool_call_id", None)
+
+ if not supports_image:
+ content = new_msg.get("content")
+ if isinstance(content, list):
+ filtered_parts: list = []
+ removed_any_image = False
+ for part in content:
+ if isinstance(part, dict):
+ part_type = str(part.get("type", "")).lower()
+ if part_type in {"image_url", "image"}:
+ removed_any_image = True
+ removed_image_blocks += 1
+ continue
+ filtered_parts.append(part)
+ if removed_any_image:
+ new_msg["content"] = filtered_parts
+
+ if role == "assistant":
+ content = new_msg.get("content")
+ has_tool_calls = bool(new_msg.get("tool_calls"))
+ if not has_tool_calls:
+ if not content:
+ continue
+ if isinstance(content, str) and not content.strip():
+ continue
+
+ sanitized_contexts.append(new_msg)
+
+ if removed_image_blocks or removed_tool_messages or removed_tool_calls:
+ logger.debug(
+ "sanitize_context_by_modalities applied: "
+ "removed_image_blocks=%s, removed_tool_messages=%s, removed_tool_calls=%s",
+ removed_image_blocks,
+ removed_tool_messages,
+ removed_tool_calls,
+ )
+ req.contexts = sanitized_contexts
+
+
+def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
+ if event.plugins_name is not None and req.func_tool:
+ new_tool_set = ToolSet()
+ for tool in req.func_tool.tools:
+ mp = tool.handler_module_path
+ if not mp:
+ continue
+ plugin = star_map.get(mp)
+ if not plugin:
+ continue
+ if plugin.name in event.plugins_name or plugin.reserved:
+ new_tool_set.add_tool(tool)
+ req.func_tool = new_tool_set
+
+
+async def _handle_webchat(
+ event: AstrMessageEvent, req: ProviderRequest, prov: Provider
+) -> None:
+ from astrbot.core import db_helper
+
+ chatui_session_id = event.session_id.split("!")[-1]
+ user_prompt = req.prompt
+ session = await db_helper.get_platform_session_by_id(chatui_session_id)
+
+ if not user_prompt or not chatui_session_id or not session or session.display_name:
+ return
+
+ llm_resp = await prov.text_chat(
+ system_prompt=(
+ "You are a conversation title generator. "
+ "Generate a concise title in the same language as the user’s input, "
+ "no more than 10 words, capturing only the core topic."
+ "If the input is a greeting, small talk, or has no clear topic, "
+ "(e.g., “hi”, “hello”, “haha”), return . "
+ "Output only the title itself or , with no explanations."
+ ),
+ prompt=f"Generate a concise title for the following user query:\n{user_prompt}",
+ )
+ if llm_resp and llm_resp.completion_text:
+ title = llm_resp.completion_text.strip()
+ if not title or "" in title:
+ return
+ logger.info(
+ "Generated chatui title for session %s: %s", chatui_session_id, title
+ )
+ await db_helper.update_platform_session(
+ session_id=chatui_session_id,
+ display_name=title,
+ )
+
+
+def _apply_llm_safety_mode(config: MainAgentBuildConfig, req: ProviderRequest) -> None:
+ if config.safety_mode_strategy == "system_prompt":
+ req.system_prompt = (
+ f"{LLM_SAFETY_MODE_SYSTEM_PROMPT}\n\n{req.system_prompt or ''}"
+ )
+ else:
+ logger.warning(
+ "Unsupported llm_safety_mode strategy: %s.",
+ config.safety_mode_strategy,
+ )
+
+
+def _apply_sandbox_tools(
+ config: MainAgentBuildConfig, req: ProviderRequest, session_id: str
+) -> None:
+ if req.func_tool is None:
+ req.func_tool = ToolSet()
+ if config.sandbox_cfg.get("booter") == "shipyard":
+ ep = config.sandbox_cfg.get("shipyard_endpoint", "")
+ at = config.sandbox_cfg.get("shipyard_access_token", "")
+ if not ep or not at:
+ logger.error("Shipyard sandbox configuration is incomplete.")
+ return
+ os.environ["SHIPYARD_ENDPOINT"] = ep
+ os.environ["SHIPYARD_ACCESS_TOKEN"] = at
+ req.func_tool.add_tool(EXECUTE_SHELL_TOOL)
+ req.func_tool.add_tool(PYTHON_TOOL)
+ req.func_tool.add_tool(FILE_UPLOAD_TOOL)
+ req.func_tool.add_tool(FILE_DOWNLOAD_TOOL)
+ req.system_prompt += f"\n{SANDBOX_MODE_PROMPT}\n"
+
+
+def _proactive_cron_job_tools(req: ProviderRequest, event: AstrMessageEvent) -> None:
+ if req.func_tool is None:
+ req.func_tool = ToolSet()
+ req.func_tool.add_tool(CREATE_CRON_JOB_TOOL)
+ req.func_tool.add_tool(DELETE_CRON_JOB_TOOL)
+ req.func_tool.add_tool(LIST_CRON_JOBS_TOOL)
+
+
+def _get_compress_provider(
+ config: MainAgentBuildConfig, plugin_context: Context
+) -> Provider | None:
+ if not config.llm_compress_provider_id:
+ return None
+ if config.context_limit_reached_strategy != "llm_compress":
+ return None
+ provider = plugin_context.get_provider_by_id(config.llm_compress_provider_id)
+ if provider is None:
+ logger.warning(
+ "未找到指定的上下文压缩模型 %s,将跳过压缩。",
+ config.llm_compress_provider_id,
+ )
+ return None
+ if not isinstance(provider, Provider):
+ logger.warning(
+ "指定的上下文压缩模型 %s 不是对话模型,将跳过压缩。",
+ config.llm_compress_provider_id,
+ )
+ return None
+ return provider
+
+
+async def build_main_agent(
+ *,
+ event: AstrMessageEvent,
+ plugin_context: Context,
+ config: MainAgentBuildConfig,
+ provider: Provider | None = None,
+ req: ProviderRequest | None = None,
+) -> MainAgentBuildResult | None:
+ """构建主对话代理(Main Agent),并且自动 reset。"""
+ provider = provider or _select_provider(event, plugin_context)
+ if provider is None:
+ logger.info("未找到任何对话模型(提供商),跳过 LLM 请求处理。")
+ return None
+
+ if req is None:
+ if event.get_extra("provider_request"):
+ req = event.get_extra("provider_request")
+ assert isinstance(req, ProviderRequest), (
+ "provider_request 必须是 ProviderRequest 类型。"
+ )
+ if req.conversation:
+ req.contexts = json.loads(req.conversation.history)
+ else:
+ req = ProviderRequest()
+ req.prompt = ""
+ req.image_urls = []
+ if sel_model := event.get_extra("selected_model"):
+ req.model = sel_model
+ if config.provider_wake_prefix and not event.message_str.startswith(
+ config.provider_wake_prefix
+ ):
+ return None
+
+ req.prompt = event.message_str[len(config.provider_wake_prefix) :]
+ for comp in event.message_obj.message:
+ if isinstance(comp, Image):
+ image_path = await comp.convert_to_file_path()
+ req.image_urls.append(image_path)
+ req.extra_user_content_parts.append(
+ TextPart(text=f"[Image Attachment: path {image_path}]")
+ )
+ elif isinstance(comp, File):
+ file_path = await comp.get_file()
+ file_name = comp.name or os.path.basename(file_path)
+ req.extra_user_content_parts.append(
+ TextPart(
+ text=f"[File Attachment: name {file_name}, path {file_path}]"
+ )
+ )
+
+ conversation = await _get_session_conv(event, plugin_context)
+ req.conversation = conversation
+ req.contexts = json.loads(conversation.history)
+ event.set_extra("provider_request", req)
+
+ if isinstance(req.contexts, str):
+ req.contexts = json.loads(req.contexts)
+
+ if config.file_extract_enabled:
+ try:
+ await _apply_file_extract(event, req, config)
+ except Exception as exc: # noqa: BLE001
+ logger.error("Error occurred while applying file extract: %s", exc)
+
+ if not req.prompt and not req.image_urls:
+ if not event.get_group_id() and req.extra_user_content_parts:
+ req.prompt = ""
+ else:
+ return None
+
+ await _apply_kb(event, req, plugin_context, config)
+
+ if not req.session_id:
+ req.session_id = event.unified_msg_origin
+
+ _modalities_fix(provider, req)
+ _plugin_tool_fix(event, req)
+ _sanitize_context_by_modalities(config, provider, req)
+
+ if config.llm_safety_mode:
+ _apply_llm_safety_mode(config, req)
+
+ if config.sandbox_cfg.get("enable", False):
+ _apply_sandbox_tools(config, req, req.session_id)
+
+ agent_runner = AgentRunner()
+ astr_agent_ctx = AstrAgentContext(
+ context=plugin_context,
+ event=event,
+ )
+
+ _proactive_cron_job_tools(req, event)
+
+ if provider.provider_config.get("max_context_tokens", 0) <= 0:
+ model = provider.get_model()
+ if model_info := LLM_METADATAS.get(model):
+ provider.provider_config["max_context_tokens"] = model_info["limit"][
+ "context"
+ ]
+
+ if event.get_platform_name() == "webchat":
+ asyncio.create_task(_handle_webchat(event, req, provider))
+ req.system_prompt += f"\n{CHATUI_EXTRA_PROMPT}\n"
+
+ if req.func_tool and req.func_tool.tools:
+ tool_prompt = (
+ TOOL_CALL_PROMPT
+ if config.tool_schema_mode == "full"
+ else TOOL_CALL_PROMPT_SKILLS_LIKE_MODE
+ )
+ req.system_prompt += f"\n{tool_prompt}\n"
+
+ action_type = event.get_extra("action_type")
+ if action_type == "live":
+ req.system_prompt += f"\n{LIVE_MODE_SYSTEM_PROMPT}\n"
+
+ await agent_runner.reset(
+ provider=provider,
+ request=req,
+ run_context=AgentContextWrapper(
+ context=astr_agent_ctx,
+ tool_call_timeout=config.tool_call_timeout,
+ ),
+ tool_executor=FunctionToolExecutor(),
+ agent_hooks=MAIN_AGENT_HOOKS,
+ streaming=config.streaming_response,
+ llm_compress_instruction=config.llm_compress_instruction,
+ llm_compress_keep_recent=config.llm_compress_keep_recent,
+ llm_compress_provider=_get_compress_provider(config, plugin_context),
+ truncate_turns=config.dequeue_context_length,
+ enforce_max_turns=config.max_context_length,
+ tool_schema_mode=config.tool_schema_mode,
+ )
+
+ return MainAgentBuildResult(
+ agent_runner=agent_runner,
+ provider_request=req,
+ provider=provider,
+ )
diff --git a/astrbot/core/pipeline/process_stage/utils.py b/astrbot/core/astr_main_agent_resources.py
similarity index 98%
rename from astrbot/core/pipeline/process_stage/utils.py
rename to astrbot/core/astr_main_agent_resources.py
index 1b44f17520..10554cbae3 100644
--- a/astrbot/core/pipeline/process_stage/utils.py
+++ b/astrbot/core/astr_main_agent_resources.py
@@ -165,7 +165,9 @@ async def call(
try:
target_session = (
- MessageSession.from_str(session) if isinstance(session, str) else session
+ MessageSession.from_str(session)
+ if isinstance(session, str)
+ else session
)
except Exception as e:
return f"error: invalid session: {e}"
diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py
index 6a0088afff..96ee1611ec 100644
--- a/astrbot/core/core_lifecycle.py
+++ b/astrbot/core/core_lifecycle.py
@@ -163,7 +163,7 @@ async def initialize(self) -> None:
self.kb_manager = KnowledgeBaseManager(self.provider_manager)
# 初始化 CronJob 管理器
- self.cron_manager = CronJobManager(self.star_context, self.db)
+ self.cron_manager = CronJobManager(self.db)
# 初始化提供给插件的上下文
self.star_context = Context(
@@ -231,7 +231,7 @@ def _load(self) -> None:
cron_task = None
if self.cron_manager:
cron_task = asyncio.create_task(
- self.cron_manager.start(),
+ self.cron_manager.start(self.star_context),
name="cron_manager",
)
diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py
index 8a4ced6e7c..a877d45cef 100644
--- a/astrbot/core/cron/manager.py
+++ b/astrbot/core/cron/manager.py
@@ -11,20 +11,27 @@
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import CronJob
from astrbot.core.platform.message_session import MessageSession
+from astrbot.core.message.message_event_result import MessageChain
+from astrbot.core.message.components import Plain
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from astrbot.core.star.context import Context
class CronJobManager:
"""Central scheduler for BasicCronJob and ActiveAgentCronJob."""
- def __init__(self, ctx, db: BaseDatabase):
- self.ctx = ctx
+ def __init__(self, db: BaseDatabase):
self.db = db
self.scheduler = AsyncIOScheduler()
self._basic_handlers: dict[str, Callable[..., Any]] = {}
self._lock = asyncio.Lock()
self._started = False
- async def start(self):
+ async def start(self, ctx: "Context"):
+ self.ctx: Context = ctx # star context
async with self._lock:
if self._started:
return
@@ -219,19 +226,21 @@ async def _run_active_agent_job(self, job: CronJob):
"cron_payload": payload,
}
- await self._dispatch_agent_event(
+ await self._woke_main_agent(
message=note,
session_str=session_str,
extras=extras,
)
- async def _dispatch_agent_event(
+ async def _woke_main_agent(
self,
*,
message: str,
session_str: str,
- extras: dict | None = None,
+ extras: dict,
):
+ from astrbot.core.astr_main_agent import build_main_agent, MainAgentBuildConfig
+
try:
session = (
session_str
@@ -250,7 +259,43 @@ async def _dispatch_agent_event(
message_type=session.message_type,
)
- await self.ctx.get_event_queue().put(cron_event)
+ config = MainAgentBuildConfig(tool_call_timeout=3600)
+ result = await build_main_agent(
+ event=cron_event, plugin_context=self.ctx, config=config
+ )
+ if not result:
+ logger.error("Failed to build main agent for cron job.")
+ return
+ req = result.provider_request
+ runner = result.agent_runner
+
+ # finetine the messages
+ job_name = extras.get("name", "scheduled task")
+ note = extras.get("note") or extras.get("description") or ""
+ if req.contexts:
+ context_dump = req._print_friendly_context()
+ req.system_prompt += (
+ "\n\nBellow is you and user previous conversation history:\n"
+ f"{context_dump}"
+ )
+ req.system_prompt += (
+ "\n[Scheduler Context] This turn is triggered automatically by cron job "
+ f'"{job_name}" (type: {extras.get("type", "unknown")}). '
+ "Act proactively based on the provided note and current context. "
+ )
+ if note:
+ req.system_prompt += f"[Scheduler Note]: {note}\n"
+
+ req.prompt = "You are now responding to a scheduled task. Output using same language as previous conversation."
+
+ async for _ in runner.step_until_done(30):
+ pass
+ llm_resp = runner.get_final_llm_resp()
+ if not llm_resp:
+ logger.warning("Cron job agent got no response")
+ return
+ message_chain = MessageChain(chain=[Plain(text=llm_resp.completion_text)])
+ await self.ctx.send_message(session=session, message_chain=message_chain)
__all__ = ["CronJobManager"]
diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
index 57da5193cd..9a1fad0d2e 100644
--- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
+++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
@@ -1,61 +1,36 @@
"""本地 Agent 模式的 LLM 调用 Stage"""
import asyncio
-import json
-import os
+import base64
from collections.abc import AsyncGenerator
from astrbot.core import logger
-from astrbot.core.agent.message import Message, TextPart
+from astrbot.core.agent.message import Message
from astrbot.core.agent.response import AgentStats
-from astrbot.core.agent.tool import ToolSet
-from astrbot.core.astr_agent_context import AstrAgentContext
-from astrbot.core.conversation_mgr import Conversation
-from astrbot.core.message.components import File, Image, Reply
+from astrbot.core.message.components import File, Image
from astrbot.core.message.message_event_result import (
MessageChain,
MessageEventResult,
ResultContentType,
)
from astrbot.core.platform.astr_message_event import AstrMessageEvent
-from astrbot.core.provider import Provider
from astrbot.core.provider.entities import (
LLMResponse,
ProviderRequest,
)
-from astrbot.core.star.star_handler import EventType, star_map
-from astrbot.core.utils.file_extract import extract_file_moonshotai
-from astrbot.core.utils.llm_metadata import LLM_METADATAS
+from astrbot.core.star.star_handler import EventType
from astrbot.core.utils.metrics import Metric
from astrbot.core.utils.session_lock import session_lock_manager
-from .....astr_agent_context import AgentContextWrapper
-from .....astr_agent_hooks import MAIN_AGENT_HOOKS
-from .....astr_agent_run_util import AgentRunner, run_agent, run_live_agent
-from .....astr_agent_tool_exec import FunctionToolExecutor
+from .....astr_agent_run_util import run_agent, run_live_agent
from ....context import PipelineContext, call_event_hook
from ...stage import Stage
-from ...utils import (
- CHATUI_EXTRA_PROMPT,
- EXECUTE_SHELL_TOOL,
- FILE_DOWNLOAD_TOOL,
- FILE_UPLOAD_TOOL,
- KNOWLEDGE_BASE_QUERY_TOOL,
- LIVE_MODE_SYSTEM_PROMPT,
- LLM_SAFETY_MODE_SYSTEM_PROMPT,
- PYTHON_TOOL,
- SANDBOX_MODE_PROMPT,
- TOOL_CALL_PROMPT,
- TOOL_CALL_PROMPT_SKILLS_LIKE_MODE,
- SEND_MESSAGE_TO_USER_TOOL,
- decoded_blocked,
- retrieve_knowledge_base,
-)
-from astrbot.core.tools.cron_tools import (
- CREATE_CRON_JOB_TOOL,
- DELETE_CRON_JOB_TOOL,
- LIST_CRON_JOBS_TOOL,
+from astrbot.core.astr_main_agent import (
+ MainAgentBuildConfig,
+ MainAgentBuildResult,
+ build_main_agent,
)
+from dataclasses import replace
class InternalAgentSubStage(Stage):
@@ -121,453 +96,35 @@ async def initialize(self, ctx: PipelineContext) -> None:
self.conv_manager = ctx.plugin_manager.context.conversation_manager
- def _select_provider(self, event: AstrMessageEvent):
- """选择使用的 LLM 提供商"""
- sel_provider = event.get_extra("selected_provider")
- _ctx = self.ctx.plugin_manager.context
- if sel_provider and isinstance(sel_provider, str):
- provider = _ctx.get_provider_by_id(sel_provider)
- if not provider:
- logger.error(f"未找到指定的提供商: {sel_provider}。")
- return provider
- try:
- prov = _ctx.get_using_provider(umo=event.unified_msg_origin)
- except ValueError as e:
- logger.error(f"Error occurred while selecting provider: {e}")
- return None
- return prov
-
- async def _get_session_conv(self, event: AstrMessageEvent) -> Conversation:
- umo = event.unified_msg_origin
- conv_mgr = self.conv_manager
-
- # 获取对话上下文
- cid = await conv_mgr.get_curr_conversation_id(umo)
- if not cid:
- cid = await conv_mgr.new_conversation(umo, event.get_platform_id())
- conversation = await conv_mgr.get_conversation(umo, cid)
- if not conversation:
- cid = await conv_mgr.new_conversation(umo, event.get_platform_id())
- conversation = await conv_mgr.get_conversation(umo, cid)
- if not conversation:
- raise RuntimeError("无法创建新的对话。")
- return conversation
-
- async def _apply_kb(
- self,
- event: AstrMessageEvent,
- req: ProviderRequest,
- ):
- """Apply knowledge base context to the provider request"""
- if not self.kb_agentic_mode:
- if req.prompt is None:
- return
- try:
- kb_result = await retrieve_knowledge_base(
- query=req.prompt,
- umo=event.unified_msg_origin,
- context=self.ctx.plugin_manager.context,
- )
- if not kb_result:
- return
- if req.system_prompt is not None:
- req.system_prompt += (
- f"\n\n[Related Knowledge Base Results]:\n{kb_result}"
- )
- except Exception as e:
- logger.error(f"Error occurred while retrieving knowledge base: {e}")
- else:
- if req.func_tool is None:
- req.func_tool = ToolSet()
- req.func_tool.add_tool(KNOWLEDGE_BASE_QUERY_TOOL)
-
- async def _apply_file_extract(
- self,
- event: AstrMessageEvent,
- req: ProviderRequest,
- ):
- """Apply file extract to the provider request"""
- file_paths = []
- file_names = []
- for comp in event.message_obj.message:
- if isinstance(comp, File):
- file_paths.append(await comp.get_file())
- file_names.append(comp.name)
- elif isinstance(comp, Reply) and comp.chain:
- for reply_comp in comp.chain:
- if isinstance(reply_comp, File):
- file_paths.append(await reply_comp.get_file())
- file_names.append(reply_comp.name)
- if not file_paths:
- return
- if not req.prompt:
- req.prompt = "总结一下文件里面讲了什么?"
- if self.file_extract_prov == "moonshotai":
- if not self.file_extract_msh_api_key:
- logger.error("Moonshot AI API key for file extract is not set")
- return
- file_contents = await asyncio.gather(
- *[
- extract_file_moonshotai(file_path, self.file_extract_msh_api_key)
- for file_path in file_paths
- ]
- )
- else:
- logger.error(f"Unsupported file extract provider: {self.file_extract_prov}")
- return
-
- # add file extract results to contexts
- for file_content, file_name in zip(file_contents, file_names):
- req.contexts.append(
- {
- "role": "system",
- "content": f"File Extract Results of user uploaded files:\n{file_content}\nFile Name: {file_name or 'Unknown'}",
- },
- )
-
- def _modalities_fix(
- self,
- provider: Provider,
- req: ProviderRequest,
- ):
- """检查提供商的模态能力,清理请求中的不支持内容"""
- if req.image_urls:
- provider_cfg = provider.provider_config.get("modalities", ["image"])
- if "image" not in provider_cfg:
- logger.debug(
- f"用户设置提供商 {provider} 不支持图像,将图像替换为占位符。"
- )
- # 为每个图片添加占位符到 prompt
- image_count = len(req.image_urls)
- placeholder = " ".join(["[图片]"] * image_count)
- if req.prompt:
- req.prompt = f"{placeholder} {req.prompt}"
- else:
- req.prompt = placeholder
- req.image_urls = []
- if req.func_tool:
- provider_cfg = provider.provider_config.get("modalities", ["tool_use"])
- # 如果模型不支持工具使用,但请求中包含工具列表,则清空。
- if "tool_use" not in provider_cfg:
- logger.debug(
- f"用户设置提供商 {provider} 不支持工具使用,清空工具列表。",
- )
- req.func_tool = None
-
- def _sanitize_context_by_modalities(
- self,
- provider: Provider,
- req: ProviderRequest,
- ) -> None:
- """Sanitize `req.contexts` (including history) by current provider modalities."""
- if not self.sanitize_context_by_modalities:
- return
-
- if not isinstance(req.contexts, list) or not req.contexts:
- return
-
- modalities = provider.provider_config.get("modalities", None)
- # if modalities is not configured, do not sanitize.
- if not modalities or not isinstance(modalities, list):
- return
-
- supports_image = bool("image" in modalities)
- supports_tool_use = bool("tool_use" in modalities)
-
- if supports_image and supports_tool_use:
- return
-
- sanitized_contexts: list[dict] = []
- removed_image_blocks = 0
- removed_tool_messages = 0
- removed_tool_calls = 0
-
- for msg in req.contexts:
- if not isinstance(msg, dict):
- continue
-
- role = msg.get("role")
- if not role:
- continue
-
- new_msg: dict = msg
-
- # tool_use sanitize
- if not supports_tool_use:
- if role == "tool":
- # tool response block
- removed_tool_messages += 1
- continue
- if role == "assistant" and "tool_calls" in new_msg:
- # assistant message with tool calls
- if "tool_calls" in new_msg:
- removed_tool_calls += 1
- new_msg.pop("tool_calls", None)
- new_msg.pop("tool_call_id", None)
-
- # image sanitize
- if not supports_image:
- content = new_msg.get("content")
- if isinstance(content, list):
- filtered_parts: list = []
- removed_any_image = False
- for part in content:
- if isinstance(part, dict):
- part_type = str(part.get("type", "")).lower()
- if part_type in {"image_url", "image"}:
- removed_any_image = True
- removed_image_blocks += 1
- continue
- filtered_parts.append(part)
-
- if removed_any_image:
- new_msg["content"] = filtered_parts
-
- # drop empty assistant messages (e.g. only tool_calls without content)
- if role == "assistant":
- content = new_msg.get("content")
- has_tool_calls = bool(new_msg.get("tool_calls"))
- if not has_tool_calls:
- if not content:
- continue
- if isinstance(content, str) and not content.strip():
- continue
-
- sanitized_contexts.append(new_msg)
-
- if removed_image_blocks or removed_tool_messages or removed_tool_calls:
- logger.debug(
- "sanitize_context_by_modalities applied: "
- f"removed_image_blocks={removed_image_blocks}, "
- f"removed_tool_messages={removed_tool_messages}, "
- f"removed_tool_calls={removed_tool_calls}"
- )
-
- req.contexts = sanitized_contexts
-
- def _plugin_tool_fix(
- self,
- event: AstrMessageEvent,
- req: ProviderRequest,
- ):
- """根据事件中的插件设置,过滤请求中的工具列表"""
- if event.plugins_name is not None and req.func_tool:
- new_tool_set = ToolSet()
- for tool in req.func_tool.tools:
- mp = tool.handler_module_path
- if not mp:
- continue
- plugin = star_map.get(mp)
- if not plugin:
- continue
- if plugin.name in event.plugins_name or plugin.reserved:
- new_tool_set.add_tool(tool)
- req.func_tool = new_tool_set
-
- async def _handle_webchat(
- self,
- event: AstrMessageEvent,
- req: ProviderRequest,
- prov: Provider,
- ):
- """处理 WebChat 平台的特殊情况,包括第一次 LLM 对话时总结对话内容生成 title"""
- from astrbot.core import db_helper
-
- chatui_session_id = event.session_id.split("!")[-1]
- user_prompt = req.prompt
-
- session = await db_helper.get_platform_session_by_id(chatui_session_id)
-
- if (
- not user_prompt
- or not chatui_session_id
- or not session
- or session.display_name
- ):
- return
-
- llm_resp = await prov.text_chat(
- system_prompt=(
- "You are a conversation title generator. "
- "Generate a concise title in the same language as the user’s input, "
- "no more than 10 words, capturing only the core topic."
- "If the input is a greeting, small talk, or has no clear topic, "
- "(e.g., “hi”, “hello”, “haha”), return . "
- "Output only the title itself or , with no explanations."
- ),
- prompt=(
- f"Generate a concise title for the following user query:\n{user_prompt}"
- ),
- )
- if llm_resp and llm_resp.completion_text:
- title = llm_resp.completion_text.strip()
- if not title or "" in title:
- return
- logger.info(
- f"Generated chatui title for session {chatui_session_id}: {title}"
- )
- await db_helper.update_platform_session(
- session_id=chatui_session_id,
- display_name=title,
- )
-
- async def _save_to_history(
- self,
- event: AstrMessageEvent,
- req: ProviderRequest,
- llm_response: LLMResponse | None,
- all_messages: list[Message],
- runner_stats: AgentStats | None,
- ):
- if (
- not req
- or not req.conversation
- or not llm_response
- or llm_response.role != "assistant"
- ):
- return
-
- if not llm_response.completion_text and not req.tool_calls_result:
- logger.debug("LLM 响应为空,不保存记录。")
- return
-
- # using agent context messages to save to history
- message_to_save = []
- skipped_initial_system = False
- for message in all_messages:
- if message.role == "system" and not skipped_initial_system:
- skipped_initial_system = True
- continue # skip first system message
- if message.role in ["assistant", "user"] and getattr(
- message, "_no_save", None
- ):
- # we do not save user and assistant messages that are marked as _no_save
- continue
- message_to_save.append(message.model_dump())
-
- # get token usage from agent runner stats
- token_usage = None
- if runner_stats:
- token_usage = runner_stats.token_usage.total
-
- await self.conv_manager.update_conversation(
- event.unified_msg_origin,
- req.conversation.cid,
- history=message_to_save,
- token_usage=token_usage,
- )
-
- def _get_compress_provider(self) -> Provider | None:
- if not self.llm_compress_provider_id:
- return None
- if self.context_limit_reached_strategy != "llm_compress":
- return None
- provider = self.ctx.plugin_manager.context.get_provider_by_id(
- self.llm_compress_provider_id,
+ self.main_agent_cfg = MainAgentBuildConfig(
+ tool_call_timeout=self.tool_call_timeout,
+ tool_schema_mode=self.tool_schema_mode,
+ sanitize_context_by_modalities=self.sanitize_context_by_modalities,
+ kb_agentic_mode=self.kb_agentic_mode,
+ file_extract_enabled=self.file_extract_enabled,
+ file_extract_prov=self.file_extract_prov,
+ file_extract_msh_api_key=self.file_extract_msh_api_key,
+ context_limit_reached_strategy=self.context_limit_reached_strategy,
+ llm_compress_instruction=self.llm_compress_instruction,
+ llm_compress_keep_recent=self.llm_compress_keep_recent,
+ llm_compress_provider_id=self.llm_compress_provider_id,
+ max_context_length=self.max_context_length,
+ dequeue_context_length=self.dequeue_context_length,
+ llm_safety_mode=self.llm_safety_mode,
+ safety_mode_strategy=self.safety_mode_strategy,
+ sandbox_cfg=self.sandbox_cfg,
)
- if provider is None:
- logger.warning(
- f"未找到指定的上下文压缩模型 {self.llm_compress_provider_id},将跳过压缩。",
- )
- return None
- if not isinstance(provider, Provider):
- logger.warning(
- f"指定的上下文压缩模型 {self.llm_compress_provider_id} 不是对话模型,将跳过压缩。"
- )
- return None
- return provider
-
- def _apply_llm_safety_mode(self, req: ProviderRequest) -> None:
- """Apply LLM safety mode to the provider request."""
- if self.safety_mode_strategy == "system_prompt":
- req.system_prompt = (
- f"{LLM_SAFETY_MODE_SYSTEM_PROMPT}\n\n{req.system_prompt or ''}"
- )
- else:
- logger.warning(
- f"Unsupported llm_safety_mode strategy: {self.safety_mode_strategy}.",
- )
-
- def _apply_sandbox_tools(self, req: ProviderRequest, session_id: str) -> None:
- """Add sandbox tools to the provider request."""
- if req.func_tool is None:
- req.func_tool = ToolSet()
- if self.sandbox_cfg.get("booter") == "shipyard":
- ep = self.sandbox_cfg.get("shipyard_endpoint", "")
- at = self.sandbox_cfg.get("shipyard_access_token", "")
- if not ep or not at:
- logger.error("Shipyard sandbox configuration is incomplete.")
- return
- os.environ["SHIPYARD_ENDPOINT"] = ep
- os.environ["SHIPYARD_ACCESS_TOKEN"] = at
- req.func_tool.add_tool(EXECUTE_SHELL_TOOL)
- req.func_tool.add_tool(PYTHON_TOOL)
- req.func_tool.add_tool(FILE_UPLOAD_TOOL)
- req.func_tool.add_tool(FILE_DOWNLOAD_TOOL)
- req.system_prompt += f"\n{SANDBOX_MODE_PROMPT}\n"
-
- def _proactive_cron_job_tools(
- self, req: ProviderRequest, event: AstrMessageEvent
- ) -> None:
- """Inject cron job context and tools into the provider request for proactive scheduling."""
-
- if req.func_tool is None:
- req.func_tool = ToolSet()
- req.func_tool.add_tool(CREATE_CRON_JOB_TOOL)
- req.func_tool.add_tool(DELETE_CRON_JOB_TOOL)
- req.func_tool.add_tool(LIST_CRON_JOBS_TOOL)
-
- cron_meta = event.get_extra("cron_job")
- if cron_meta:
- # The message event is triggered by a known cron job
- if req.func_tool is None:
- req.func_tool = ToolSet()
- req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL)
-
- job_name = cron_meta.get("name", "scheduled task")
- note = cron_meta.get("note") or cron_meta.get("description") or ""
- req.system_prompt += (
- f"\n[Scheduler Context] This turn is triggered automatically by cron job "
- f'"{job_name}" (type: {cron_meta.get("type", "unknown")}). '
- "Act proactively based on the provided note and current context. "
- "If you want to proactively notify the user, call `send_message_to_user` with a concise message.\n"
- )
- if note:
- req.system_prompt += f"[Scheduler Note]: {note}\n"
-
- if bg := event.get_extra("background_task_result"):
- # The message event is triggered after a background task done
- result_text = bg.get("result") or ""
- if req.func_tool is None:
- req.func_tool = ToolSet()
- req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL)
- if result_text:
- req.system_prompt += f"\n[Background Task Result] {result_text}\n"
async def process(
self, event: AstrMessageEvent, provider_wake_prefix: str
) -> AsyncGenerator[None, None]:
- req: ProviderRequest | None = None
-
try:
- provider = self._select_provider(event)
- if provider is None:
- logger.info("未找到任何对话模型(提供商),跳过 LLM 请求处理。")
- return
- if not isinstance(provider, Provider):
- logger.error(
- f"选择的提供商类型无效({type(provider)}),跳过 LLM 请求处理。"
- )
- return
-
streaming_response = self.streaming_response
if (enable_streaming := event.get_extra("enable_streaming")) is not None:
streaming_response = bool(enable_streaming)
- # 检查消息内容是否有效,避免空消息触发钩子
has_provider_request = event.get_extra("provider_request") is not None
has_valid_message = bool(event.message_str and event.message_str.strip())
- # 检查是否有图片或其他媒体内容
has_media_content = any(
isinstance(comp, Image | File) for comp in event.message_obj.message
)
@@ -580,179 +137,50 @@ async def process(
logger.debug("skip llm request: empty message and no provider_request")
return
- api_base = provider.provider_config.get("api_base", "")
- for host in decoded_blocked:
- if host in api_base:
- logger.error(
- f"Provider API base {api_base} is blocked due to security reasons. Please use another ai provider."
- )
- return
-
logger.debug("ready to request llm provider")
- # 通知等待调用 LLM(在获取锁之前)
await call_event_hook(event, EventType.OnWaitingLLMRequestEvent)
async with session_lock_manager.acquire_lock(event.unified_msg_origin):
logger.debug("acquired session lock for llm request")
- if event.get_extra("provider_request"):
- req = event.get_extra("provider_request")
- assert isinstance(req, ProviderRequest), (
- "provider_request 必须是 ProviderRequest 类型。"
- )
-
- if req.conversation:
- req.contexts = json.loads(req.conversation.history)
-
- else:
- req = ProviderRequest()
- req.prompt = ""
- req.image_urls = []
- if sel_model := event.get_extra("selected_model"):
- req.model = sel_model
- if provider_wake_prefix and not event.message_str.startswith(
- provider_wake_prefix
- ):
- return
-
- req.prompt = event.message_str[len(provider_wake_prefix) :]
- # func_tool selection 现在已经转移到 astrbot/builtin_stars/astrbot 插件中进行选择。
- # req.func_tool = self.ctx.plugin_manager.context.get_llm_tool_manager()
- for comp in event.message_obj.message:
- if isinstance(comp, Image):
- image_path = await comp.convert_to_file_path()
- req.image_urls.append(image_path)
- req.extra_user_content_parts.append(
- TextPart(text=f"[Image Attachment: path {image_path}]")
- )
- elif isinstance(comp, File):
- file_path = await comp.get_file()
- file_name = comp.name or os.path.basename(file_path)
- req.extra_user_content_parts.append(
- TextPart(
- text=f"[File Attachment: name {file_name}, path {file_path}]"
- )
- )
-
- conversation = await self._get_session_conv(event)
- req.conversation = conversation
- req.contexts = json.loads(conversation.history)
-
- event.set_extra("provider_request", req)
-
- # fix contexts json str
- if isinstance(req.contexts, str):
- req.contexts = json.loads(req.contexts)
-
- # apply file extract
- if self.file_extract_enabled:
- try:
- await self._apply_file_extract(event, req)
- except Exception as e:
- logger.error(f"Error occurred while applying file extract: {e}")
+ build_cfg = replace(
+ self.main_agent_cfg,
+ provider_wake_prefix=provider_wake_prefix,
+ streaming_response=streaming_response,
+ )
- if not req.prompt and not req.image_urls:
- if not event.get_group_id() and req.extra_user_content_parts:
- req.prompt = ""
- else:
- return
+ build_result: MainAgentBuildResult | None = await build_main_agent(
+ event=event,
+ plugin_context=self.ctx.plugin_manager.context,
+ config=build_cfg,
+ )
- # call event hook
- if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
+ if build_result is None:
return
- # apply knowledge base feature
- await self._apply_kb(event, req)
-
- # truncate contexts to fit max length
- # NOW moved to ContextManager inside ToolLoopAgentRunner
- # if req.contexts:
- # req.contexts = self._truncate_contexts(req.contexts)
- # self._fix_messages(req.contexts)
-
- # session_id
- if not req.session_id:
- req.session_id = event.unified_msg_origin
-
- # check provider modalities, if provider does not support image/tool_use, clear them in request.
- self._modalities_fix(provider, req)
-
- # filter tools, only keep tools from this pipeline's selected plugins
- self._plugin_tool_fix(event, req)
+ agent_runner = build_result.agent_runner
+ req = build_result.provider_request
+ provider = build_result.provider
- # sanitize contexts (including history) by provider modalities
- self._sanitize_context_by_modalities(provider, req)
-
- # apply llm safety mode
- if self.llm_safety_mode:
- self._apply_llm_safety_mode(req)
-
- # apply sandbox tools
- if self.sandbox_cfg.get("enable", False):
- self._apply_sandbox_tools(req, req.session_id)
+ api_base = provider.provider_config.get("api_base", "")
+ for host in decoded_blocked:
+ if host in api_base:
+ logger.error(
+ "Provider API base %s is blocked due to security reasons. Please use another ai provider.",
+ api_base,
+ )
+ return
stream_to_general = (
self.unsupported_streaming_strategy == "turn_off"
and not event.platform_meta.support_streaming_message
)
- # run agent
- agent_runner = AgentRunner()
- logger.debug(
- f"handle provider[id: {provider.provider_config['id']}] request: {req}",
- )
- astr_agent_ctx = AstrAgentContext(
- context=self.ctx.plugin_manager.context,
- event=event,
- )
-
- # inject model context length limit
- if provider.provider_config.get("max_context_tokens", 0) <= 0:
- model = provider.get_model()
- if model_info := LLM_METADATAS.get(model):
- provider.provider_config["max_context_tokens"] = model_info[
- "limit"
- ]["context"]
-
- # ChatUI 对话的标题生成
- if event.get_platform_name() == "webchat":
- asyncio.create_task(self._handle_webchat(event, req, provider))
-
- # 注入 ChatUI 额外 prompt
- # 比如 follow-up questions 提示等
- req.system_prompt += f"\n{CHATUI_EXTRA_PROMPT}\n"
-
- # 注入基本 prompt
- if req.func_tool and req.func_tool.tools:
- tool_prompt = (
- TOOL_CALL_PROMPT
- if self.tool_schema_mode == "full"
- else TOOL_CALL_PROMPT_SKILLS_LIKE_MODE
- )
- req.system_prompt += f"\n{tool_prompt}\n"
+ if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
+ return
action_type = event.get_extra("action_type")
- if action_type == "live":
- req.system_prompt += f"\n{LIVE_MODE_SYSTEM_PROMPT}\n"
-
- await agent_runner.reset(
- provider=provider,
- request=req,
- run_context=AgentContextWrapper(
- context=astr_agent_ctx,
- tool_call_timeout=self.tool_call_timeout,
- ),
- tool_executor=FunctionToolExecutor(),
- agent_hooks=MAIN_AGENT_HOOKS,
- streaming=streaming_response,
- llm_compress_instruction=self.llm_compress_instruction,
- llm_compress_keep_recent=self.llm_compress_keep_recent,
- llm_compress_provider=self._get_compress_provider(),
- truncate_turns=self.dequeue_context_length,
- enforce_max_turns=self.max_context_length,
- tool_schema_mode=self.tool_schema_mode,
- )
# 检测 Live Mode
if action_type == "live":
@@ -865,3 +293,52 @@ async def process(
f"Error occurred while processing agent request: {e}"
)
)
+
+ async def _save_to_history(
+ self,
+ event: AstrMessageEvent,
+ req: ProviderRequest,
+ llm_response: LLMResponse | None,
+ all_messages: list[Message],
+ runner_stats: AgentStats | None,
+ ):
+ if (
+ not req
+ or not req.conversation
+ or not llm_response
+ or llm_response.role != "assistant"
+ ):
+ return
+
+ if not llm_response.completion_text and not req.tool_calls_result:
+ logger.debug("LLM 响应为空,不保存记录。")
+ return
+
+ message_to_save = []
+ skipped_initial_system = False
+ for message in all_messages:
+ if message.role == "system" and not skipped_initial_system:
+ skipped_initial_system = True
+ continue
+ if message.role in ["assistant", "user"] and getattr(
+ message, "_no_save", None
+ ):
+ continue
+ message_to_save.append(message.model_dump())
+
+ token_usage = None
+ if runner_stats:
+ token_usage = runner_stats.token_usage.total
+
+ await self.conv_manager.update_conversation(
+ event.unified_msg_origin,
+ req.conversation.cid,
+ history=message_to_save,
+ token_usage=token_usage,
+ )
+
+
+# we prevent astrbot from connecting to known malicious hosts
+# these hosts are base64 encoded
+BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"}
+decoded_blocked = [base64.b64decode(b).decode("utf-8") for b in BLOCKED]
diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py
index a1a6039f4a..7c568626d5 100644
--- a/astrbot/core/provider/entities.py
+++ b/astrbot/core/provider/entities.py
@@ -165,7 +165,7 @@ def _print_friendly_context(self):
result_parts.append(f"{role}: {''.join(msg_parts)}")
- return result_parts
+ return "\n".join(result_parts)
async def assemble_context(self) -> dict:
"""将请求(prompt 和 image_urls)包装成 OpenAI 的消息格式。"""
From 7f58a83833f6f27cfd4b40f56d35aff527f74bfb Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sun, 1 Feb 2026 14:32:30 +0800
Subject: [PATCH 11/24] Refactor cron job handling and enhance proactive agent
capabilities
- Updated FunctionToolExecutor to improve background task handling and integrate new system prompts for proactive agents.
- Enhanced MainAgentBuildConfig with additional configuration options for tool management and context handling.
- Introduced new system prompts for proactive agents triggered by cron jobs and background tasks to improve user interaction.
- Refactored cron job management to utilize ProviderRequest for better context management and tool integration.
- Renamed cron job tools for clarity, changing "create_cron_job" to "create_future_task" and similar adjustments for consistency.
- Improved error handling and logging for cron job execution and agent responses.
- Added support for image captioning and persona management in agent requests.
---
astrbot/builtin_stars/astrbot/main.py | 5 -
.../astrbot/process_llm_request.py | 401 ---------------
astrbot/core/astr_agent_tool_exec.py | 65 +--
astrbot/core/astr_main_agent.py | 464 +++++++++++++++++-
astrbot/core/astr_main_agent_resources.py | 48 +-
astrbot/core/config/default.py | 2 +-
astrbot/core/cron/manager.py | 70 ++-
.../method/agent_sub_stages/internal.py | 3 +
astrbot/core/tools/cron_tools.py | 24 +-
9 files changed, 586 insertions(+), 496 deletions(-)
delete mode 100644 astrbot/builtin_stars/astrbot/process_llm_request.py
diff --git a/astrbot/builtin_stars/astrbot/main.py b/astrbot/builtin_stars/astrbot/main.py
index b3ea355b1e..56066c5616 100644
--- a/astrbot/builtin_stars/astrbot/main.py
+++ b/astrbot/builtin_stars/astrbot/main.py
@@ -7,7 +7,6 @@
from astrbot.core import logger
from .long_term_memory import LongTermMemory
-from .process_llm_request import ProcessLLMRequest
class Main(star.Star):
@@ -19,8 +18,6 @@ def __init__(self, context: star.Context) -> None:
except BaseException as e:
logger.error(f"聊天增强 err: {e}")
- self.proc_llm_req = ProcessLLMRequest(self.context)
-
def ltm_enabled(self, event: AstrMessageEvent):
ltmse = self.context.get_config(umo=event.unified_msg_origin)[
"provider_ltm_settings"
@@ -91,8 +88,6 @@ async def on_message(self, event: AstrMessageEvent):
@filter.on_llm_request()
async def decorate_llm_req(self, event: AstrMessageEvent, req: ProviderRequest):
"""在请求 LLM 前注入人格信息、Identifier、时间、回复内容等 System Prompt"""
- await self.proc_llm_req.process_llm_request(event, req)
-
if self.ltm and self.ltm_enabled(event):
try:
await self.ltm.on_req_llm(event, req)
diff --git a/astrbot/builtin_stars/astrbot/process_llm_request.py b/astrbot/builtin_stars/astrbot/process_llm_request.py
deleted file mode 100644
index 2ecfeac49b..0000000000
--- a/astrbot/builtin_stars/astrbot/process_llm_request.py
+++ /dev/null
@@ -1,401 +0,0 @@
-import builtins
-import copy
-import datetime
-import zoneinfo
-
-from astrbot.api import logger, sp, star
-from astrbot.api.event import AstrMessageEvent
-from astrbot.api.message_components import Image, Reply
-from astrbot.api.provider import Provider, ProviderRequest
-from astrbot.core.agent.handoff import HandoffTool
-from astrbot.core.agent.message import TextPart
-from astrbot.core.astr_main_agent_resources import (
- CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT,
- LOCAL_EXECUTE_SHELL_TOOL,
- LOCAL_PYTHON_TOOL,
-)
-from astrbot.core.provider.func_tool_manager import ToolSet
-from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
-
-
-class ProcessLLMRequest:
- def __init__(self, context: star.Context):
- self.ctx = context
- cfg = context.get_config()
- self.timezone = cfg.get("timezone")
- if not self.timezone:
- # 系统默认时区
- self.timezone = None
- else:
- logger.info(f"Timezone set to: {self.timezone}")
-
- self.skill_manager = SkillManager()
-
- def _apply_local_env_tools(self, req: ProviderRequest) -> None:
- """Add local environment tools to the provider request."""
- if req.func_tool is None:
- req.func_tool = ToolSet()
- req.func_tool.add_tool(LOCAL_EXECUTE_SHELL_TOOL)
- req.func_tool.add_tool(LOCAL_PYTHON_TOOL)
-
- async def _ensure_persona(
- self,
- req: ProviderRequest,
- cfg: dict,
- umo: str,
- platform_type: str,
- event: AstrMessageEvent,
- ):
- """确保用户人格已加载"""
- if not req.conversation:
- return
- # persona inject
-
- # custom rule is preferred
- persona_id = (
- await sp.get_async(
- scope="umo", scope_id=umo, key="session_service_config", default={}
- )
- ).get("persona_id")
-
- if not persona_id:
- persona_id = req.conversation.persona_id or cfg.get("default_personality")
- if not persona_id and persona_id != "[%None]": # [%None] 为用户取消人格
- default_persona = self.ctx.persona_manager.selected_default_persona_v3
- if default_persona:
- persona_id = default_persona["name"]
-
- # ChatUI special default persona
- if platform_type == "webchat":
- # non-existent persona_id to let following codes not working
- persona_id = "_chatui_default_"
- req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
-
- persona = next(
- builtins.filter(
- lambda persona: persona["name"] == persona_id,
- self.ctx.persona_manager.personas_v3,
- ),
- None,
- )
- if persona:
- if prompt := persona["prompt"]:
- req.system_prompt += prompt
- if begin_dialogs := copy.deepcopy(persona["_begin_dialogs_processed"]):
- req.contexts[:0] = begin_dialogs
-
- # skills select and prompt
- runtime = self.skills_cfg.get("runtime", "local")
- skills = self.skill_manager.list_skills(active_only=True, runtime=runtime)
- if runtime == "sandbox" and not self.sandbox_cfg.get("enable", False):
- logger.warning(
- "Skills runtime is set to sandbox, but sandbox mode is disabled, will skip skills prompt injection.",
- )
- 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"
- elif skills:
- # persona.skills == None means all skills are allowed
- if persona and persona.get("skills") is not None:
- if not persona["skills"]:
- return
- allowed = set(persona["skills"])
- skills = [skill for skill in skills if skill.name in allowed]
- if skills:
- req.system_prompt += f"\n{build_skills_prompt(skills)}\n"
-
- # if user wants to use skills in non-sandbox mode, apply local env tools
- runtime = self.skills_cfg.get("runtime", "local")
- sandbox_enabled = self.sandbox_cfg.get("enable", False)
- if runtime == "local" and not sandbox_enabled:
- self._apply_local_env_tools(req)
-
- # tools select
- tmgr = self.ctx.get_llm_tool_manager()
-
- # SubAgent orchestrator mode: main LLM only sees handoff tools.
- # NOTE: subagent_orchestrator config lives at top-level now.
- orch_cfg = self.ctx.get_config().get("subagent_orchestrator", {})
- if orch_cfg.get("main_enable", False):
- policy = str(orch_cfg.get("main_tools_policy", "handoff_only")).strip()
- if policy not in {"handoff_only", "unassigned_to_main"}:
- # Prefer the safer default when config contains unknown values.
- policy = "handoff_only"
-
- assigned_tools: set[str] = set()
- agents = orch_cfg.get("agents", [])
- if isinstance(agents, list):
- for a in agents:
- if not isinstance(a, dict):
- continue
- if a.get("enabled", True) is False:
- continue
- persona_tools = None
- persona_id = a.get("persona_id")
- if persona_id:
- persona_tools = next(
- (
- p.get("tools")
- for p in self.ctx.persona_manager.personas_v3
- if p["name"] == persona_id
- ),
- None,
- )
- tools = a.get("tools", [])
- if persona_tools is not None:
- tools = persona_tools
- if tools is None:
- assigned_tools.update(
- [
- tool.name
- for tool in tmgr.func_list
- if not isinstance(tool, HandoffTool)
- ]
- )
- continue
- if not isinstance(tools, list):
- continue
- for t in tools:
- name = str(t).strip()
- if name:
- assigned_tools.add(name)
-
- toolset = ToolSet()
-
- # Always expose handoff tools (transfer_to_*) when orchestrator is enabled.
- for tool in tmgr.func_list:
- if isinstance(tool, HandoffTool) and tool.active:
- toolset.add_tool(tool)
-
- # Optional mode: keep tools that are not assigned to any subagent on the main LLM.
- if policy == "unassigned_to_main":
- for tool in tmgr.func_list:
- if not tool.active:
- continue
- if isinstance(tool, HandoffTool):
- continue
- if tool.handler_module_path == "core.subagent_orchestrator":
- continue
- if tool.name in assigned_tools:
- continue
- toolset.add_tool(tool)
-
- # Override any earlier tool injection (e.g. skills local env tools) to keep
- # main-LLM tool visibility predictable under subagent orchestrator.
- req.func_tool = toolset
-
- # Encourage the model to delegate to subagents.
- # Use the built-in default router prompt; user overrides are disabled for now.
- router_prompt = (
- self.ctx.get_config()
- .get("subagent_orchestrator", {})
- .get("router_system_prompt", "")
- ).strip()
- if router_prompt:
- req.system_prompt += f"\n{router_prompt}\n"
-
- if policy == "unassigned_to_main":
- req.system_prompt += (
- "\n[Note: You may directly call the tools visible to the main LLM "
- "if they are not assigned to any subagent; otherwise prefer delegating "
- "to subagents via transfer_to_*.]\n"
- )
-
- return
-
- # Default behavior: follow persona tool selection.
- if (persona and persona.get("tools") is None) or not persona:
- # select all
- toolset = tmgr.get_full_tool_set()
- for tool in toolset:
- if not tool.active:
- toolset.remove_tool(tool.name)
- else:
- toolset = ToolSet()
- if persona["tools"]:
- for tool_name in persona["tools"]:
- tool = tmgr.get_func(tool_name)
- if tool and tool.active:
- toolset.add_tool(tool)
- if not req.func_tool:
- req.func_tool = toolset
- else:
- req.func_tool.merge(toolset)
- event.trace.record(
- "sel_persona", persona_id=persona_id, persona_toolset=toolset.names()
- )
- logger.debug(f"Tool set for persona {persona_id}: {toolset.names()}")
-
- async def _ensure_img_caption(
- self,
- req: ProviderRequest,
- cfg: dict,
- img_cap_prov_id: str,
- ):
- try:
- caption = await self._request_img_caption(
- img_cap_prov_id,
- cfg,
- req.image_urls,
- )
- if caption:
- req.extra_user_content_parts.append(
- TextPart(text=f"{caption}")
- )
- req.image_urls = []
- except Exception as e:
- logger.error(f"处理图片描述失败: {e}")
-
- async def _request_img_caption(
- self,
- provider_id: str,
- cfg: dict,
- image_urls: list[str],
- ) -> str:
- if prov := self.ctx.get_provider_by_id(provider_id):
- if isinstance(prov, Provider):
- img_cap_prompt = cfg.get(
- "image_caption_prompt",
- "Please describe the image.",
- )
- logger.debug(f"Processing image caption with provider: {provider_id}")
- llm_resp = await prov.text_chat(
- prompt=img_cap_prompt,
- image_urls=image_urls,
- )
- return llm_resp.completion_text
- raise ValueError(
- f"Cannot get image caption because provider `{provider_id}` is not a valid Provider, it is {type(prov)}.",
- )
- raise ValueError(
- f"Cannot get image caption because provider `{provider_id}` is not exist.",
- )
-
- async def process_llm_request(self, event: AstrMessageEvent, req: ProviderRequest):
- """在请求 LLM 前注入人格信息、Identifier、时间、回复内容等 System Prompt"""
- cfg: dict = self.ctx.get_config(umo=event.unified_msg_origin)[
- "provider_settings"
- ]
- self.skills_cfg = cfg.get("skills", {})
- self.sandbox_cfg = cfg.get("sandbox", {})
-
- # prompt prefix
- if prefix := cfg.get("prompt_prefix"):
- # 支持 {{prompt}} 作为用户输入的占位符
- if "{{prompt}}" in prefix:
- req.prompt = prefix.replace("{{prompt}}", req.prompt)
- else:
- req.prompt = prefix + req.prompt
-
- # 收集系统提醒信息
- system_parts = []
-
- # user identifier
- if cfg.get("identifier"):
- user_id = event.message_obj.sender.user_id
- user_nickname = event.message_obj.sender.nickname
- system_parts.append(f"User ID: {user_id}, Nickname: {user_nickname}")
-
- # group name identifier
- if cfg.get("group_name_display") and event.message_obj.group_id:
- if not event.message_obj.group:
- logger.error(
- f"Group name display enabled but group object is None. Group ID: {event.message_obj.group_id}"
- )
- return
- group_name = event.message_obj.group.group_name
- if group_name:
- system_parts.append(f"Group name: {group_name}")
-
- # time info
- if cfg.get("datetime_system_prompt"):
- current_time = None
- if self.timezone:
- # 启用时区
- try:
- now = datetime.datetime.now(zoneinfo.ZoneInfo(self.timezone))
- current_time = now.strftime("%Y-%m-%d %H:%M (%Z)")
- except Exception as e:
- logger.error(f"时区设置错误: {e}, 使用本地时区")
- if not current_time:
- current_time = (
- datetime.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M (%Z)")
- )
- system_parts.append(f"Current datetime: {current_time}")
-
- img_cap_prov_id: str = cfg.get("default_image_caption_provider_id") or ""
- if req.conversation:
- # inject persona for this request
- platform_type = event.get_platform_name()
- await self._ensure_persona(
- req, cfg, event.unified_msg_origin, platform_type, event
- )
-
- # image caption
- if img_cap_prov_id and req.image_urls:
- await self._ensure_img_caption(req, cfg, img_cap_prov_id)
-
- # quote message processing
- # 解析引用内容
- quote = None
- for comp in event.message_obj.message:
- if isinstance(comp, Reply):
- quote = comp
- break
- if quote:
- content_parts = []
-
- # 1. 处理引用的文本
- sender_info = (
- f"({quote.sender_nickname}): " if quote.sender_nickname else ""
- )
- message_str = quote.message_str or "[Empty Text]"
- content_parts.append(f"{sender_info}{message_str}")
-
- # 2. 处理引用的图片 (保留原有逻辑,但改变输出目标)
- image_seg = None
- if quote.chain:
- for comp in quote.chain:
- if isinstance(comp, Image):
- image_seg = comp
- break
-
- if image_seg:
- try:
- # 找到可以生成图片描述的 provider
- prov = None
- if img_cap_prov_id:
- prov = self.ctx.get_provider_by_id(img_cap_prov_id)
- if prov is None:
- prov = self.ctx.get_using_provider(event.unified_msg_origin)
-
- # 调用 provider 生成图片描述
- if prov and isinstance(prov, Provider):
- llm_resp = await prov.text_chat(
- prompt="Please describe the image content.",
- image_urls=[await image_seg.convert_to_file_path()],
- )
- if llm_resp.completion_text:
- # 将图片描述作为文本添加到 content_parts
- content_parts.append(
- f"[Image Caption in quoted message]: {llm_resp.completion_text}"
- )
- else:
- logger.warning(
- "No provider found for image captioning in quote."
- )
- except BaseException as e:
- logger.error(f"处理引用图片失败: {e}")
-
- # 3. 将所有部分组合成文本并添加到 extra_user_content_parts 中
- # 确保引用内容被正确的标签包裹
- quoted_content = "\n".join(content_parts)
- # 确保所有内容都在标签内
- quoted_text = f"\n{quoted_content}\n"
-
- req.extra_user_content_parts.append(TextPart(text=quoted_text))
-
- # 统一包裹所有系统提醒
- if system_parts:
- system_content = (
- "" + "\n".join(system_parts) + ""
- )
- req.extra_user_content_parts.append(TextPart(text=system_content))
diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py
index 91f8b3e216..d238f757b1 100644
--- a/astrbot/core/astr_agent_tool_exec.py
+++ b/astrbot/core/astr_agent_tool_exec.py
@@ -3,6 +3,7 @@
import traceback
import typing as T
import uuid
+import json
import mcp
@@ -20,9 +21,13 @@
MessageChain,
MessageEventResult,
)
+from astrbot.core.provider.entites import ProviderRequest
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.provider.register import llm_tools
-from astrbot.core.message.components import Plain
+from astrbot.core.astr_main_agent_resources import (
+ BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT,
+ SEND_MESSAGE_TO_USER_TOOL,
+)
class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
@@ -148,7 +153,11 @@ async def _execute_background(
task_id: str,
**tool_args,
):
- from astrbot.core.astr_main_agent import build_main_agent, MainAgentBuildConfig
+ from astrbot.core.astr_main_agent import (
+ build_main_agent,
+ MainAgentBuildConfig,
+ _get_session_conv,
+ )
# run the tool
result_text = ""
@@ -191,47 +200,47 @@ async def _execute_background(
message_type=session.message_type,
)
config = MainAgentBuildConfig(tool_call_timeout=3600)
- result = await build_main_agent(
- event=cron_event, plugin_context=ctx, config=config
- )
- if not result:
- logger.error("Failed to build main agent for cron job.")
- return
- runner = result.agent_runner
- req = result.provider_request
- bg = extras["background_task_result"]
- result_text = bg["result"] or "Empty Response"
- if req.contexts:
+ req = ProviderRequest()
+ conv = await _get_session_conv(event=cron_event, plugin_context=ctx)
+ req.conversation = conv
+ context = json.loads(conv.history)
+ if context:
+ req.contexts = context
context_dump = req._print_friendly_context()
+ req.contexts = []
req.system_prompt += (
"\n\nBellow is you and user previous conversation history:\n"
f"{context_dump}"
)
- req.system_prompt += (
- "You now have a new background task result:\n"
- f"- Task ID: {bg['task_id']}\n"
- f"- Executed Tool: {tool.name}\n"
- f"- Tool Args: {tool_args}\n"
- f"- Result: {result_text}\n"
- f"- Note: {note}\n"
- "Please tell the user the result of the background task in your next response."
- )
+ bg = json.dumps(extras["background_task_result"], ensure_ascii=False)
+ req.system_prompt += BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT.format(
+ background_task_result=bg
+ )
req.prompt = (
- "You have a new background task result to report to the user."
- " Please include the result in your next response."
- " Using same language as previous conversation."
+ "Proceed according to your system instructions. "
+ "Output using same language as previous conversation."
)
+ if not req.func_tool:
+ req.func_tool = ToolSet()
+ req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL)
+ result = await build_main_agent(
+ event=cron_event, plugin_context=ctx, config=config, req=req
+ )
+ if not result:
+ logger.error("Failed to build main agent for background task job.")
+ return
+
+ runner = result.agent_runner
async for _ in runner.step_until_done(30):
+ # agent will send message to user via using tools
pass
llm_resp = runner.get_final_llm_resp()
if not llm_resp:
- logger.warning("Cron job agent got no response")
+ logger.warning("background task agent got no response")
return
- message_chain = MessageChain(chain=[Plain(text=llm_resp.completion_text)])
- await ctx.send_message(session=session, message_chain=message_chain)
@classmethod
async def _execute_local(
diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
index b35bd971b4..e58625bf1b 100644
--- a/astrbot/core/astr_main_agent.py
+++ b/astrbot/core/astr_main_agent.py
@@ -1,22 +1,46 @@
from __future__ import annotations
import asyncio
+import builtins
+import copy
+import datetime
import json
import os
+import zoneinfo
from dataclasses import dataclass, field
+from astrbot.api import sp
from astrbot.core import logger
+from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.message import TextPart
from astrbot.core.agent.tool import ToolSet
from astrbot.core.astr_agent_context import AgentContextWrapper, AstrAgentContext
from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS
from astrbot.core.astr_agent_run_util import AgentRunner
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
+from astrbot.core.astr_main_agent_resources import (
+ CHATUI_EXTRA_PROMPT,
+ CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT,
+ EXECUTE_SHELL_TOOL,
+ FILE_DOWNLOAD_TOOL,
+ FILE_UPLOAD_TOOL,
+ KNOWLEDGE_BASE_QUERY_TOOL,
+ LIVE_MODE_SYSTEM_PROMPT,
+ LOCAL_EXECUTE_SHELL_TOOL,
+ LOCAL_PYTHON_TOOL,
+ LLM_SAFETY_MODE_SYSTEM_PROMPT,
+ PYTHON_TOOL,
+ SANDBOX_MODE_PROMPT,
+ TOOL_CALL_PROMPT,
+ TOOL_CALL_PROMPT_SKILLS_LIKE_MODE,
+ retrieve_knowledge_base,
+)
from astrbot.core.conversation_mgr import Conversation
from astrbot.core.message.components import File, Image, Reply
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.provider import Provider
from astrbot.core.provider.entities import ProviderRequest
+from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
from astrbot.core.star.context import Context
from astrbot.core.star.star_handler import star_map
from astrbot.core.tools.cron_tools import (
@@ -27,42 +51,59 @@
from astrbot.core.utils.file_extract import extract_file_moonshotai
from astrbot.core.utils.llm_metadata import LLM_METADATAS
-from .astr_main_agent_resources import (
- CHATUI_EXTRA_PROMPT,
- EXECUTE_SHELL_TOOL,
- FILE_DOWNLOAD_TOOL,
- FILE_UPLOAD_TOOL,
- KNOWLEDGE_BASE_QUERY_TOOL,
- LIVE_MODE_SYSTEM_PROMPT,
- LLM_SAFETY_MODE_SYSTEM_PROMPT,
- PYTHON_TOOL,
- SANDBOX_MODE_PROMPT,
- TOOL_CALL_PROMPT,
- TOOL_CALL_PROMPT_SKILLS_LIKE_MODE,
- retrieve_knowledge_base,
-)
-
@dataclass(slots=True)
class MainAgentBuildConfig:
+ """The main agent build configuration.
+ Most of the configs can be found in the cmd_config.json"""
+
tool_call_timeout: int
+ """The timeout (in seconds) for a tool call.
+ When the tool call exceeds this time,
+ a timeout error as a tool result will be returned.
+ """
tool_schema_mode: str = "full"
+ """The tool schema mode, can be 'full' or 'skills-like'."""
provider_wake_prefix: str = ""
+ """The wake prefix for the provider. If the user message does not start with this prefix,
+ the main agent will not be triggered."""
streaming_response: bool = True
+ """Whether to use streaming response."""
sanitize_context_by_modalities: bool = False
+ """Whether to sanitize the context based on the provider's supported modalities.
+ This will remove unsupported message types(e.g. image) from the context to prevent issues."""
kb_agentic_mode: bool = False
+ """Whether to use agentic mode for knowledge base retrieval.
+ This will inject the knowledge base query tool into the main agent's toolset to allow dynamic querying."""
file_extract_enabled: bool = False
+ """Whether to enable file content extraction for uploaded files."""
file_extract_prov: str = "moonshotai"
+ """The file extraction provider."""
file_extract_msh_api_key: str = ""
+ """The API key for Moonshot AI file extraction provider."""
context_limit_reached_strategy: str = "truncate_by_turns"
+ """The strategy to handle context length limit reached."""
llm_compress_instruction: str = ""
- llm_compress_keep_recent: int = 4
+ """The instruction for compression in llm_compress strategy."""
+ llm_compress_keep_recent: int = 6
+ """The number of most recent turns to keep during llm_compress strategy."""
llm_compress_provider_id: str = ""
- max_context_length: int = 0
+ """The provider ID for the LLM used in context compression."""
+ max_context_length: int = -1
+ """The maximum number of turns to keep in context. -1 means no limit.
+ This enforce max turns before compression"""
dequeue_context_length: int = 1
+ """The number of oldest turns to remove when context length limit is reached."""
llm_safety_mode: bool = True
+ """This will inject healthy and safe system prompt into the main agent,
+ to prevent LLM output harmful information"""
safety_mode_strategy: str = "system_prompt"
sandbox_cfg: dict = field(default_factory=dict)
+ add_cron_tools: bool = True
+ """This will add cron job management tools to the main agent for proactive cron job execution."""
+ provider_settings: dict = field(default_factory=dict)
+ subagent_orchestrator: dict = field(default_factory=dict)
+ timezone: str | None = None
@dataclass(slots=True)
@@ -189,6 +230,388 @@ async def _apply_file_extract(
)
+def _apply_prompt_prefix(req: ProviderRequest, cfg: dict) -> None:
+ prefix = cfg.get("prompt_prefix")
+ if not prefix:
+ return
+ if "{{prompt}}" in prefix:
+ req.prompt = prefix.replace("{{prompt}}", req.prompt)
+ else:
+ req.prompt = f"{prefix}{req.prompt}"
+
+
+def _apply_local_env_tools(req: ProviderRequest) -> None:
+ if req.func_tool is None:
+ req.func_tool = ToolSet()
+ req.func_tool.add_tool(LOCAL_EXECUTE_SHELL_TOOL)
+ req.func_tool.add_tool(LOCAL_PYTHON_TOOL)
+
+
+async def _ensure_persona_and_skills(
+ req: ProviderRequest,
+ cfg: dict,
+ plugin_context: Context,
+ event: AstrMessageEvent,
+) -> None:
+ """Ensure persona and skills are applied to the request's system prompt or user prompt."""
+ if not req.conversation:
+ return
+
+ # get persona ID
+ persona_id = (
+ await sp.get_async(
+ scope="umo",
+ scope_id=event.unified_msg_origin,
+ key="session_service_config",
+ default={},
+ )
+ ).get("persona_id")
+
+ if not persona_id:
+ persona_id = req.conversation.persona_id or cfg.get("default_personality")
+ if persona_id is None or persona_id != "[%None]":
+ default_persona = plugin_context.persona_manager.selected_default_persona_v3
+ if default_persona:
+ persona_id = default_persona["name"]
+ if event.get_platform_name() == "webchat":
+ persona_id = "_chatui_default_"
+ req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
+
+ persona = next(
+ builtins.filter(
+ lambda persona: persona["name"] == persona_id,
+ plugin_context.persona_manager.personas_v3,
+ ),
+ None,
+ )
+ if persona:
+ # Inject persona system prompt
+ if prompt := persona["prompt"]:
+ req.system_prompt += f"\n# Persona Instructions\n\n{prompt}\n"
+ if begin_dialogs := copy.deepcopy(persona.get("_begin_dialogs_processed")):
+ req.contexts[:0] = begin_dialogs
+
+ # Inject skills prompt
+ skills_cfg = cfg.get("skills", {})
+ sandbox_cfg = cfg.get("sandbox", {})
+ skill_manager = SkillManager()
+ runtime = skills_cfg.get("runtime", "local")
+ skills = skill_manager.list_skills(active_only=True, runtime=runtime)
+
+ if runtime == "sandbox" and not sandbox_cfg.get("enable", False):
+ logger.warning(
+ "Skills runtime is set to sandbox, but sandbox mode is disabled, will skip skills prompt injection.",
+ )
+ 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"
+ )
+ elif skills:
+ if persona and persona.get("skills") is not None:
+ if not persona["skills"]:
+ skills = []
+ else:
+ allowed = set(persona["skills"])
+ skills = [skill for skill in skills if skill.name in allowed]
+ if skills:
+ req.system_prompt += f"\n{build_skills_prompt(skills)}\n"
+
+ runtime = skills_cfg.get("runtime", "local")
+ sandbox_enabled = sandbox_cfg.get("enable", False)
+ if runtime == "local" and not sandbox_enabled:
+ _apply_local_env_tools(req)
+
+ tmgr = plugin_context.get_llm_tool_manager()
+
+ orch_cfg = plugin_context.get_config().get("subagent_orchestrator", {})
+ if orch_cfg.get("main_enable", False):
+ policy = str(orch_cfg.get("main_tools_policy", "handoff_only")).strip()
+ if policy not in {"handoff_only", "unassigned_to_main"}:
+ policy = "handoff_only"
+
+ assigned_tools: set[str] = set()
+ agents = orch_cfg.get("agents", [])
+ if isinstance(agents, list):
+ for a in agents:
+ if not isinstance(a, dict):
+ continue
+ if a.get("enabled", True) is False:
+ continue
+ persona_tools = None
+ pid = a.get("persona_id")
+ if pid:
+ persona_tools = next(
+ (
+ p.get("tools")
+ for p in plugin_context.persona_manager.personas_v3
+ if p["name"] == pid
+ ),
+ None,
+ )
+ tools = a.get("tools", [])
+ if persona_tools is not None:
+ tools = persona_tools
+ if tools is None:
+ assigned_tools.update(
+ [
+ tool.name
+ for tool in tmgr.func_list
+ if not isinstance(tool, HandoffTool)
+ ]
+ )
+ continue
+ if not isinstance(tools, list):
+ continue
+ for t in tools:
+ name = str(t).strip()
+ if name:
+ assigned_tools.add(name)
+
+ toolset = ToolSet()
+ for tool in tmgr.func_list:
+ if isinstance(tool, HandoffTool) and tool.active:
+ toolset.add_tool(tool)
+
+ if policy == "unassigned_to_main":
+ for tool in tmgr.func_list:
+ if not tool.active:
+ continue
+ if isinstance(tool, HandoffTool):
+ continue
+ if tool.handler_module_path == "core.subagent_orchestrator":
+ continue
+ if tool.name in assigned_tools:
+ continue
+ toolset.add_tool(tool)
+
+ req.func_tool = toolset
+
+ router_prompt = (
+ plugin_context.get_config()
+ .get("subagent_orchestrator", {})
+ .get("router_system_prompt", "")
+ ).strip()
+ if router_prompt:
+ req.system_prompt += f"\n{router_prompt}\n"
+ if policy == "unassigned_to_main":
+ req.system_prompt += (
+ "\n[Note: You may directly call the tools visible to the main LLM "
+ "if they are not assigned to any subagent; otherwise prefer delegating "
+ "to subagents via transfer_to_*.]\n"
+ )
+ return
+
+ # inject toolset in the persona
+ if (persona and persona.get("tools") is None) or not persona:
+ toolset = tmgr.get_full_tool_set()
+ for tool in list(toolset):
+ if not tool.active:
+ toolset.remove_tool(tool.name)
+ else:
+ toolset = ToolSet()
+ if persona["tools"]:
+ for tool_name in persona["tools"]:
+ tool = tmgr.get_func(tool_name)
+ if tool and tool.active:
+ toolset.add_tool(tool)
+ if not req.func_tool:
+ req.func_tool = toolset
+ else:
+ req.func_tool.merge(toolset)
+ try:
+ event.trace.record(
+ "sel_persona", persona_id=persona_id, persona_toolset=toolset.names()
+ )
+ except Exception:
+ pass
+ logger.debug("Tool set for persona %s: %s", persona_id, toolset.names())
+
+
+async def _request_img_caption(
+ provider_id: str,
+ cfg: dict,
+ image_urls: list[str],
+ plugin_context: Context,
+) -> str:
+ prov = plugin_context.get_provider_by_id(provider_id)
+ if prov is None:
+ raise ValueError(
+ f"Cannot get image caption because provider `{provider_id}` is not exist.",
+ )
+ if not isinstance(prov, Provider):
+ raise ValueError(
+ f"Cannot get image caption because provider `{provider_id}` is not a valid Provider, it is {type(prov)}.",
+ )
+
+ img_cap_prompt = cfg.get(
+ "image_caption_prompt",
+ "Please describe the image.",
+ )
+ logger.debug("Processing image caption with provider: %s", provider_id)
+ llm_resp = await prov.text_chat(
+ prompt=img_cap_prompt,
+ image_urls=image_urls,
+ )
+ return llm_resp.completion_text
+
+
+async def _ensure_img_caption(
+ req: ProviderRequest,
+ cfg: dict,
+ plugin_context: Context,
+ image_caption_provider: str,
+) -> None:
+ try:
+ caption = await _request_img_caption(
+ image_caption_provider,
+ cfg,
+ req.image_urls,
+ plugin_context,
+ )
+ if caption:
+ req.extra_user_content_parts.append(
+ TextPart(text=f"{caption}")
+ )
+ req.image_urls = []
+ except Exception as exc: # noqa: BLE001
+ logger.error("处理图片描述失败: %s", exc)
+
+
+async def _process_quote_message(
+ event: AstrMessageEvent,
+ req: ProviderRequest,
+ img_cap_prov_id: str,
+ plugin_context: Context,
+) -> None:
+ quote = None
+ for comp in event.message_obj.message:
+ if isinstance(comp, Reply):
+ quote = comp
+ break
+ if not quote:
+ return
+
+ content_parts = []
+ sender_info = f"({quote.sender_nickname}): " if quote.sender_nickname else ""
+ message_str = quote.message_str or "[Empty Text]"
+ content_parts.append(f"{sender_info}{message_str}")
+
+ image_seg = None
+ if quote.chain:
+ for comp in quote.chain:
+ if isinstance(comp, Image):
+ image_seg = comp
+ break
+
+ if image_seg:
+ try:
+ prov = None
+ if img_cap_prov_id:
+ prov = plugin_context.get_provider_by_id(img_cap_prov_id)
+ if prov is None:
+ prov = plugin_context.get_using_provider(event.unified_msg_origin)
+
+ if prov and isinstance(prov, Provider):
+ llm_resp = await prov.text_chat(
+ prompt="Please describe the image content.",
+ image_urls=[await image_seg.convert_to_file_path()],
+ )
+ if llm_resp.completion_text:
+ content_parts.append(
+ f"[Image Caption in quoted message]: {llm_resp.completion_text}"
+ )
+ else:
+ logger.warning("No provider found for image captioning in quote.")
+ except BaseException as exc:
+ logger.error("处理引用图片失败: %s", exc)
+
+ quoted_content = "\n".join(content_parts)
+ quoted_text = f"\n{quoted_content}\n"
+ req.extra_user_content_parts.append(TextPart(text=quoted_text))
+
+
+def _append_system_reminders(
+ event: AstrMessageEvent,
+ req: ProviderRequest,
+ cfg: dict,
+ timezone: str | None,
+) -> None:
+ system_parts: list[str] = []
+ if cfg.get("identifier"):
+ user_id = event.message_obj.sender.user_id
+ user_nickname = event.message_obj.sender.nickname
+ system_parts.append(f"User ID: {user_id}, Nickname: {user_nickname}")
+
+ if cfg.get("group_name_display") and event.message_obj.group_id:
+ if not event.message_obj.group:
+ logger.error(
+ "Group name display enabled but group object is None. Group ID: %s",
+ event.message_obj.group_id,
+ )
+ else:
+ group_name = event.message_obj.group.group_name
+ if group_name:
+ system_parts.append(f"Group name: {group_name}")
+
+ if cfg.get("datetime_system_prompt"):
+ current_time = None
+ if timezone:
+ try:
+ now = datetime.datetime.now(zoneinfo.ZoneInfo(timezone))
+ current_time = now.strftime("%Y-%m-%d %H:%M (%Z)")
+ except Exception as exc: # noqa: BLE001
+ logger.error("时区设置错误: %s, 使用本地时区", exc)
+ if not current_time:
+ current_time = (
+ datetime.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M (%Z)")
+ )
+ system_parts.append(f"Current datetime: {current_time}")
+
+ if system_parts:
+ system_content = (
+ "" + "\n".join(system_parts) + ""
+ )
+ req.extra_user_content_parts.append(TextPart(text=system_content))
+
+
+async def _decorate_llm_request(
+ event: AstrMessageEvent,
+ req: ProviderRequest,
+ plugin_context: Context,
+ config: MainAgentBuildConfig,
+) -> None:
+ cfg = config.provider_settings or plugin_context.get_config(
+ umo=event.unified_msg_origin
+ ).get("provider_settings", {})
+
+ _apply_prompt_prefix(req, cfg)
+
+ if req.conversation:
+ await _ensure_persona_and_skills(req, cfg, plugin_context, event)
+
+ img_cap_prov_id: str = cfg.get("default_image_caption_provider_id") or ""
+ if img_cap_prov_id and req.image_urls:
+ await _ensure_img_caption(
+ req,
+ cfg,
+ plugin_context,
+ img_cap_prov_id,
+ )
+
+ img_cap_prov_id = cfg.get("default_image_caption_provider_id") or ""
+ await _process_quote_message(
+ event,
+ req,
+ img_cap_prov_id,
+ plugin_context,
+ )
+
+ tz = config.timezone
+ if tz is None:
+ tz = plugin_context.get_config().get("timezone")
+ _append_system_reminders(event, req, cfg, tz)
+
+
def _modalities_fix(provider: Provider, req: ProviderRequest) -> None:
if req.image_urls:
provider_cfg = provider.provider_config.get("modalities", ["image"])
@@ -373,7 +796,7 @@ def _apply_sandbox_tools(
req.system_prompt += f"\n{SANDBOX_MODE_PROMPT}\n"
-def _proactive_cron_job_tools(req: ProviderRequest, event: AstrMessageEvent) -> None:
+def _proactive_cron_job_tools(req: ProviderRequest) -> None:
if req.func_tool is None:
req.func_tool = ToolSet()
req.func_tool.add_tool(CREATE_CRON_JOB_TOOL)
@@ -474,6 +897,8 @@ async def build_main_agent(
else:
return None
+ await _decorate_llm_request(event, req, plugin_context, config)
+
await _apply_kb(event, req, plugin_context, config)
if not req.session_id:
@@ -495,7 +920,8 @@ async def build_main_agent(
event=event,
)
- _proactive_cron_job_tools(req, event)
+ if config.add_cron_tools:
+ _proactive_cron_job_tools(req)
if provider.provider_config.get("max_context_tokens", 0) <= 0:
model = provider.get_model()
diff --git a/astrbot/core/astr_main_agent_resources.py b/astrbot/core/astr_main_agent_resources.py
index 10554cbae3..37bf318e39 100644
--- a/astrbot/core/astr_main_agent_resources.py
+++ b/astrbot/core/astr_main_agent_resources.py
@@ -41,11 +41,12 @@
)
TOOL_CALL_PROMPT = (
- "You MUST NOT return an empty response, especially after invoking a tool."
- " Before calling any tool, provide a brief explanatory message to the user stating the purpose of the tool call."
- " Use the provided tool schema to format arguments and do not guess parameters that are not defined."
- " After the tool call is completed, you must briefly summarize the results returned by the tool for the user."
- " Keep the role-play and style consistent throughout the conversation."
+ "When using tools: "
+ "never return an empty response; "
+ "briefly explain the purpose before calling a tool; "
+ "follow the tool schema exactly and do not invent parameters; "
+ "after execution, briefly summarize the result for the user; "
+ "keep the conversation style consistent."
)
TOOL_CALL_PROMPT_SKILLS_LIKE_MODE = (
@@ -91,6 +92,43 @@
"Sound like a real conversation, not a Q&A system."
)
+PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT = (
+ "You are an autonomous proactive agent.\n\n"
+ "You are awakened by a scheduled cron job, not by a user message.\n"
+ "You are given:"
+ "1. A cron job description explaining why you are activated.\n"
+ "2. Historical conversation context between you and the user.\n"
+ "3. Your available tools and skills.\n"
+ "# IMPORTANT RULES\n"
+ "1. This is NOT a chat turn. Do NOT greet the user. Do NOT ask the user questions unless strictly necessary.\n"
+ "2. Use historical conversation and memory to understand you and user's relationship, preferences, and context.\n"
+ "3. If messaging the user: Explain WHY you are contacting them; Reference the cron task implicitly (not technical details).\n"
+ "4. You can use your available tools and skills to finish the task if needed.\n"
+ "5. Use `send_message_to_user` tool to send message to user if needed."
+ "# CRON JOB CONTEXT\n"
+ "The following object describes the scheduled task that triggered you:\n"
+ "{cron_job}"
+)
+
+BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT = (
+ "You are an autonomous proactive agent.\n\n"
+ "You are awakened by the completion of a background task you initiated earlier.\n"
+ "You are given:"
+ "1. A description of the background task you initiated.\n"
+ "2. The result of the background task.\n"
+ "3. Historical conversation context between you and the user.\n"
+ "4. Your available tools and skills.\n"
+ "# IMPORTANT RULES\n"
+ "1. This is NOT a chat turn. Do NOT greet the user. Do NOT ask the user questions unless strictly necessary. Do NOT respond if no meaningful action is required."
+ "2. Use historical conversation and memory to understand you and user's relationship, preferences, and context."
+ "3. If messaging the user: Explain WHY you are contacting them; Reference the background task implicitly (not technical details)."
+ "4. You can use your available tools and skills to finish the task if needed.\n"
+ "5. Use `send_message_to_user` tool to send message to user if needed."
+ "# BACKGROUND TASK CONTEXT\n"
+ "The following object describes the background task that completed:\n"
+ "{background_task_result}"
+)
+
@dataclass
class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index d911cd4479..702316d2e3 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -91,7 +91,7 @@
"3. If there was an initial user goal, state it first and describe the current progress/status.\n"
"4. Write the summary in the user's language.\n"
),
- "llm_compress_keep_recent": 4,
+ "llm_compress_keep_recent": 6,
"llm_compress_provider_id": "",
"max_context_length": -1,
"dequeue_context_length": 1,
diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py
index a877d45cef..64b3e2a21c 100644
--- a/astrbot/core/cron/manager.py
+++ b/astrbot/core/cron/manager.py
@@ -1,4 +1,5 @@
import asyncio
+import json
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable
from zoneinfo import ZoneInfo
@@ -7,12 +8,12 @@
from apscheduler.triggers.cron import CronTrigger
from astrbot import logger
+from astrbot.core.agent.tool import ToolSet
from astrbot.core.cron.events import CronMessageEvent
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import CronJob
from astrbot.core.platform.message_session import MessageSession
-from astrbot.core.message.message_event_result import MessageChain
-from astrbot.core.message.components import Plain
+from astrbot.core.provider.entites import ProviderRequest
from typing import TYPE_CHECKING
@@ -239,7 +240,16 @@ async def _woke_main_agent(
session_str: str,
extras: dict,
):
- from astrbot.core.astr_main_agent import build_main_agent, MainAgentBuildConfig
+ """Woke the main agent to handle the cron job message."""
+ from astrbot.core.astr_main_agent import (
+ build_main_agent,
+ MainAgentBuildConfig,
+ _get_session_conv,
+ )
+ from astrbot.core.astr_main_agent_resources import (
+ PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT,
+ SEND_MESSAGE_TO_USER_TOOL,
+ )
try:
session = (
@@ -259,43 +269,53 @@ async def _woke_main_agent(
message_type=session.message_type,
)
- config = MainAgentBuildConfig(tool_call_timeout=3600)
- result = await build_main_agent(
- event=cron_event, plugin_context=self.ctx, config=config
+ config = MainAgentBuildConfig(
+ tool_call_timeout=3600,
+ llm_safety_mode=False,
)
- if not result:
- logger.error("Failed to build main agent for cron job.")
- return
- req = result.provider_request
- runner = result.agent_runner
-
+ req = ProviderRequest()
+ conv = await _get_session_conv(event=cron_event, plugin_context=self.ctx)
+ req.conversation = conv
# finetine the messages
- job_name = extras.get("name", "scheduled task")
- note = extras.get("note") or extras.get("description") or ""
- if req.contexts:
+ context = json.loads(conv.history)
+ if context:
+ req.contexts = context
context_dump = req._print_friendly_context()
+ req.contexts = []
req.system_prompt += (
"\n\nBellow is you and user previous conversation history:\n"
- f"{context_dump}"
+ f"---\n"
+ f"{context_dump}\n"
+ f"---\n"
)
- req.system_prompt += (
- "\n[Scheduler Context] This turn is triggered automatically by cron job "
- f'"{job_name}" (type: {extras.get("type", "unknown")}). '
- "Act proactively based on the provided note and current context. "
+ cron_job_str = json.dumps(extras.get("cron_job", {}), ensure_ascii=False)
+ req.system_prompt += PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT.format(
+ cron_job=cron_job_str
)
- if note:
- req.system_prompt += f"[Scheduler Note]: {note}\n"
+ req.prompt = (
+ "You are now responding to a scheduled task"
+ "Proceed according to your system instructions. "
+ "Output using same language as previous conversation."
+ )
+ if not req.func_tool:
+ req.func_tool = ToolSet()
+ req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL)
- req.prompt = "You are now responding to a scheduled task. Output using same language as previous conversation."
+ result = await build_main_agent(
+ event=cron_event, plugin_context=self.ctx, config=config, req=req
+ )
+ if not result:
+ logger.error("Failed to build main agent for cron job.")
+ return
+ runner = result.agent_runner
async for _ in runner.step_until_done(30):
+ # agent will send message to user via using tools
pass
llm_resp = runner.get_final_llm_resp()
if not llm_resp:
logger.warning("Cron job agent got no response")
return
- message_chain = MessageChain(chain=[Plain(text=llm_resp.completion_text)])
- await self.ctx.send_message(session=session, message_chain=message_chain)
__all__ = ["CronJobManager"]
diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
index 7e218f637f..f67164821b 100644
--- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
+++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
@@ -113,6 +113,9 @@ async def initialize(self, ctx: PipelineContext) -> None:
llm_safety_mode=self.llm_safety_mode,
safety_mode_strategy=self.safety_mode_strategy,
sandbox_cfg=self.sandbox_cfg,
+ provider_settings=settings,
+ subagent_orchestrator=conf.get("subagent_orchestrator", {}),
+ timezone=self.ctx.plugin_manager.context.get_config().get("timezone"),
)
async def process(
diff --git a/astrbot/core/tools/cron_tools.py b/astrbot/core/tools/cron_tools.py
index 857a19181a..c4259aebd9 100644
--- a/astrbot/core/tools/cron_tools.py
+++ b/astrbot/core/tools/cron_tools.py
@@ -8,10 +8,10 @@
@dataclass
class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
- name: str = "create_cron_job"
+ name: str = "create_future_task"
description: str = (
- "Create a scheduled active agent task using a cron expression. "
- "Use this when the user asks for recurring tasks (e.g., daily reports)."
+ "Create a future task for your future using a cron expression. "
+ "Use this when you or the user want recurring follow-up (e.g., daily report to self)."
)
parameters: dict = Field(
default_factory=lambda: {
@@ -19,15 +19,15 @@ class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
"properties": {
"cron_expression": {
"type": "string",
- "description": "Cron expression defining when to trigger (e.g., '0 8 * * *').",
+ "description": "Cron expression defining when your future agent should wake (e.g., '0 8 * * *').",
},
"note": {
"type": "string",
- "description": "Instruction for the future agent run when the job triggers.",
+ "description": "Detailed instructions for your future agent to execute when it wakes.",
},
"name": {
"type": "string",
- "description": "Optional job name for identification.",
+ "description": "Optional label to recognize this future task.",
},
},
"required": ["cron_expression", "note"],
@@ -61,15 +61,15 @@ async def call(
)
next_run = job.next_run_time
return (
- f"Scheduled cron job {job.job_id} ({job.name}) with expression '{cron_expression}'. "
- f"Next run: {next_run}"
+ f"Scheduled future task {job.job_id} ({job.name}) with expression '{cron_expression}'. "
+ f"Your future agent will wake at: {next_run}"
)
@dataclass
class DeleteCronJobTool(FunctionTool[AstrAgentContext]):
- name: str = "delete_cron_job"
- description: str = "Delete a cron job by its job_id."
+ name: str = "delete_future_task"
+ description: str = "Delete a future task (cron job) by its job_id."
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
@@ -98,8 +98,8 @@ async def call(
@dataclass
class ListCronJobsTool(FunctionTool[AstrAgentContext]):
- name: str = "list_cron_jobs"
- description: str = "List existing cron jobs for inspection."
+ name: str = "list_future_tasks"
+ description: str = "List existing future tasks (cron jobs) for inspection."
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
From 83288ca43e24f41599c05d51153b1bbe4a8a673f Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sun, 1 Feb 2026 14:33:17 +0800
Subject: [PATCH 12/24] ruff format
---
astrbot/core/astr_agent_tool_exec.py | 16 +++++-----
astrbot/core/astr_main_agent.py | 4 +--
astrbot/core/core_lifecycle.py | 2 +-
astrbot/core/cron/events.py | 1 +
astrbot/core/cron/manager.py | 7 ++---
astrbot/core/db/po.py | 4 ++-
.../method/agent_sub_stages/internal.py | 12 ++++----
astrbot/core/star/context.py | 2 +-
astrbot/dashboard/routes/cron.py | 30 ++++++++++++++-----
9 files changed, 48 insertions(+), 30 deletions(-)
diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py
index d238f757b1..523f917eb2 100644
--- a/astrbot/core/astr_agent_tool_exec.py
+++ b/astrbot/core/astr_agent_tool_exec.py
@@ -1,33 +1,33 @@
import asyncio
import inspect
+import json
import traceback
import typing as T
import uuid
-import json
import mcp
from astrbot import logger
from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.mcp_client import MCPTool
-from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.message import Message
+from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
from astrbot.core.astr_agent_context import AstrAgentContext
+from astrbot.core.astr_main_agent_resources import (
+ BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT,
+ SEND_MESSAGE_TO_USER_TOOL,
+)
from astrbot.core.cron.events import CronMessageEvent
from astrbot.core.message.message_event_result import (
CommandResult,
MessageChain,
MessageEventResult,
)
-from astrbot.core.provider.entites import ProviderRequest
from astrbot.core.platform.message_session import MessageSession
+from astrbot.core.provider.entites import ProviderRequest
from astrbot.core.provider.register import llm_tools
-from astrbot.core.astr_main_agent_resources import (
- BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT,
- SEND_MESSAGE_TO_USER_TOOL,
-)
class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
@@ -154,9 +154,9 @@ async def _execute_background(
**tool_args,
):
from astrbot.core.astr_main_agent import (
- build_main_agent,
MainAgentBuildConfig,
_get_session_conv,
+ build_main_agent,
)
# run the tool
diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
index e58625bf1b..1695560474 100644
--- a/astrbot/core/astr_main_agent.py
+++ b/astrbot/core/astr_main_agent.py
@@ -26,9 +26,9 @@
FILE_UPLOAD_TOOL,
KNOWLEDGE_BASE_QUERY_TOOL,
LIVE_MODE_SYSTEM_PROMPT,
+ LLM_SAFETY_MODE_SYSTEM_PROMPT,
LOCAL_EXECUTE_SHELL_TOOL,
LOCAL_PYTHON_TOOL,
- LLM_SAFETY_MODE_SYSTEM_PROMPT,
PYTHON_TOOL,
SANDBOX_MODE_PROMPT,
TOOL_CALL_PROMPT,
@@ -59,7 +59,7 @@ class MainAgentBuildConfig:
tool_call_timeout: int
"""The timeout (in seconds) for a tool call.
- When the tool call exceeds this time,
+ When the tool call exceeds this time,
a timeout error as a tool result will be returned.
"""
tool_schema_mode: str = "full"
diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py
index c44d366a58..f619b64afa 100644
--- a/astrbot/core/core_lifecycle.py
+++ b/astrbot/core/core_lifecycle.py
@@ -21,11 +21,11 @@
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.config.default import VERSION
from astrbot.core.conversation_mgr import ConversationManager
+from astrbot.core.cron import CronJobManager
from astrbot.core.db import BaseDatabase
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.persona_mgr import PersonaManager
from astrbot.core.pipeline.scheduler import PipelineContext, PipelineScheduler
-from astrbot.core.cron import CronJobManager
from astrbot.core.platform.manager import PlatformManager
from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager
from astrbot.core.provider.manager import ProviderManager
diff --git a/astrbot/core/cron/events.py b/astrbot/core/cron/events.py
index 21c92e5a0c..d4f0e01e27 100644
--- a/astrbot/core/cron/events.py
+++ b/astrbot/core/cron/events.py
@@ -1,6 +1,7 @@
import time
import uuid
from typing import Any
+
from astrbot.core.message.components import Plain
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform.astr_message_event import AstrMessageEvent
diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py
index 64b3e2a21c..06e88649e7 100644
--- a/astrbot/core/cron/manager.py
+++ b/astrbot/core/cron/manager.py
@@ -1,7 +1,8 @@
import asyncio
import json
+from collections.abc import Awaitable, Callable
from datetime import datetime, timezone
-from typing import Any, Awaitable, Callable
+from typing import TYPE_CHECKING, Any
from zoneinfo import ZoneInfo
from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -15,8 +16,6 @@
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.provider.entites import ProviderRequest
-from typing import TYPE_CHECKING
-
if TYPE_CHECKING:
from astrbot.core.star.context import Context
@@ -242,9 +241,9 @@ async def _woke_main_agent(
):
"""Woke the main agent to handle the cron job message."""
from astrbot.core.astr_main_agent import (
- build_main_agent,
MainAgentBuildConfig,
_get_session_conv,
+ build_main_agent,
)
from astrbot.core.astr_main_agent_resources import (
PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT,
diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py
index b037bf9834..8068864d02 100644
--- a/astrbot/core/db/po.py
+++ b/astrbot/core/db/po.py
@@ -157,7 +157,9 @@ class CronJob(TimestampMixin, SQLModel, table=True):
)
name: str = Field(max_length=255, nullable=False)
description: str | None = Field(default=None, sa_type=Text)
- job_type: str = Field(max_length=32, nullable=False) # basic | active_agent | background
+ job_type: str = Field(
+ max_length=32, nullable=False
+ ) # basic | active_agent | background
cron_expression: str | None = Field(default=None, max_length=255)
timezone: str | None = Field(default=None, max_length=64)
payload: dict = Field(default_factory=dict, sa_type=JSON)
diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
index f67164821b..6c6b72dffc 100644
--- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
+++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
@@ -3,10 +3,16 @@
import asyncio
import base64
from collections.abc import AsyncGenerator
+from dataclasses import replace
from astrbot.core import logger
from astrbot.core.agent.message import Message
from astrbot.core.agent.response import AgentStats
+from astrbot.core.astr_main_agent import (
+ MainAgentBuildConfig,
+ MainAgentBuildResult,
+ build_main_agent,
+)
from astrbot.core.message.components import File, Image
from astrbot.core.message.message_event_result import (
MessageChain,
@@ -25,12 +31,6 @@
from .....astr_agent_run_util import run_agent, run_live_agent
from ....context import PipelineContext, call_event_hook
from ...stage import Stage
-from astrbot.core.astr_main_agent import (
- MainAgentBuildConfig,
- MainAgentBuildResult,
- build_main_agent,
-)
-from dataclasses import replace
class InternalAgentSubStage(Stage):
diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py
index a89f5db370..fee20640ae 100644
--- a/astrbot/core/star/context.py
+++ b/astrbot/core/star/context.py
@@ -10,9 +10,9 @@
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
from astrbot.core.agent.tool import ToolSet
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
-from astrbot.core.cron.manager import CronJobManager
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.conversation_mgr import ConversationManager
+from astrbot.core.cron.manager import CronJobManager
from astrbot.core.db import BaseDatabase
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.message.message_event_result import MessageChain
diff --git a/astrbot/dashboard/routes/cron.py b/astrbot/dashboard/routes/cron.py
index ae4f8e2063..df3110770f 100644
--- a/astrbot/dashboard/routes/cron.py
+++ b/astrbot/dashboard/routes/cron.py
@@ -10,7 +10,9 @@
class CronRoute(Route):
- def __init__(self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle) -> None:
+ def __init__(
+ self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle
+ ) -> None:
super().__init__(context)
self.core_lifecycle = core_lifecycle
self.routes = [
@@ -32,7 +34,9 @@ async def list_jobs(self):
try:
cron_mgr = self.core_lifecycle.cron_manager
if cron_mgr is None:
- return jsonify(Response().error("Cron manager not initialized").__dict__)
+ return jsonify(
+ Response().error("Cron manager not initialized").__dict__
+ )
job_type = request.args.get("type")
jobs = await cron_mgr.list_jobs(job_type)
data = [self._serialize_job(j) for j in jobs]
@@ -45,7 +49,9 @@ async def create_job(self):
try:
cron_mgr = self.core_lifecycle.cron_manager
if cron_mgr is None:
- return jsonify(Response().error("Cron manager not initialized").__dict__)
+ return jsonify(
+ Response().error("Cron manager not initialized").__dict__
+ )
payload = await request.json
if not isinstance(payload, dict):
@@ -62,7 +68,11 @@ async def create_job(self):
enabled = bool(payload.get("enabled", True))
if not cron_expression or not session:
- return jsonify(Response().error("cron_expression and session are required").__dict__)
+ return jsonify(
+ Response()
+ .error("cron_expression and session are required")
+ .__dict__
+ )
job_payload = {
"session": session,
@@ -73,7 +83,9 @@ async def create_job(self):
if job_type != "active_agent":
return jsonify(
- Response().error("Only active_agent jobs are supported now.").__dict__
+ Response()
+ .error("Only active_agent jobs are supported now.")
+ .__dict__
)
job = await cron_mgr.add_active_job(
@@ -94,7 +106,9 @@ async def update_job(self, job_id: str):
try:
cron_mgr = self.core_lifecycle.cron_manager
if cron_mgr is None:
- return jsonify(Response().error("Cron manager not initialized").__dict__)
+ return jsonify(
+ Response().error("Cron manager not initialized").__dict__
+ )
payload = await request.json
if not isinstance(payload, dict):
@@ -122,7 +136,9 @@ async def delete_job(self, job_id: str):
try:
cron_mgr = self.core_lifecycle.cron_manager
if cron_mgr is None:
- return jsonify(Response().error("Cron manager not initialized").__dict__)
+ return jsonify(
+ Response().error("Cron manager not initialized").__dict__
+ )
await cron_mgr.delete_job(job_id)
return jsonify(Response().ok(message="deleted").__dict__)
except Exception as e: # noqa: BLE001
From 4c8c87d3fd20d94a8f8df17f2b778bfe96dd2927 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sun, 1 Feb 2026 15:49:14 +0800
Subject: [PATCH 13/24] feat: enhance cron job management and update UI
terminology
---
.../agent/runners/tool_loop_agent_runner.py | 1 +
astrbot/core/astr_main_agent_resources.py | 18 +++-
astrbot/core/db/po.py | 2 +-
astrbot/core/skills/skill_manager.py | 17 ++--
astrbot/core/tools/cron_tools.py | 2 +-
astrbot/dashboard/routes/cron.py | 5 +
.../i18n/locales/en-US/core/navigation.json | 2 +-
.../i18n/locales/zh-CN/core/navigation.json | 2 +-
dashboard/src/views/CronJobPage.vue | 98 ++-----------------
9 files changed, 41 insertions(+), 106 deletions(-)
diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py
index 3d492783ed..03d53427fd 100644
--- a/astrbot/core/agent/runners/tool_loop_agent_runner.py
+++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py
@@ -569,6 +569,7 @@ async def _handle_function_tools(
)
],
)
+ logger.info(f"Tool `{func_tool_name}` Result: {last_tcr_content}")
# 处理函数调用响应
if tool_call_result_blocks:
diff --git a/astrbot/core/astr_main_agent_resources.py b/astrbot/core/astr_main_agent_resources.py
index 37bf318e39..779f0d0c3e 100644
--- a/astrbot/core/astr_main_agent_resources.py
+++ b/astrbot/core/astr_main_agent_resources.py
@@ -3,6 +3,7 @@
from pydantic import Field
from pydantic.dataclasses import dataclass
+import astrbot.core.message.components as Comp
from astrbot.api import logger, sp
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
@@ -183,12 +184,15 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
"type": "string",
"description": "What you want to tell the user.",
},
+ "image_path": {
+ "type": "string",
+ "description": "Optional. Send an image to the user by specifying the file path. Use an absolute path when possible; otherwise, ensure the path is relative to `data/`.",
+ },
"session": {
"type": "string",
"description": "Optional target session in format platform_id:message_type:session_id. Defaults to current session.",
},
},
- "required": ["message"],
}
)
@@ -196,11 +200,19 @@ async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> ToolExecResult:
message = str(kwargs.get("message", "")).strip()
+ image_path = kwargs.get("image_path")
session = kwargs.get("session") or context.context.event.unified_msg_origin
- if not message:
+ if not message and not image_path:
return "error: message is empty."
+ comps: list[Comp.BaseMessageComponent] = []
+
+ if message:
+ comps.append(Comp.Plain(text=message))
+ if image_path:
+ comps.append(Comp.Image.fromFileSystem(path=image_path))
+
try:
target_session = (
MessageSession.from_str(session)
@@ -212,7 +224,7 @@ async def call(
await context.context.context.send_message(
target_session,
- MessageChain().message(message),
+ MessageChain(chain=comps),
)
return f"Message sent to session {target_session}"
diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py
index 8068864d02..d676fe8457 100644
--- a/astrbot/core/db/po.py
+++ b/astrbot/core/db/po.py
@@ -159,7 +159,7 @@ class CronJob(TimestampMixin, SQLModel, table=True):
description: str | None = Field(default=None, sa_type=Text)
job_type: str = Field(
max_length=32, nullable=False
- ) # basic | active_agent | background
+ ) # basic | active_agent
cron_expression: str | None = Field(default=None, max_length=255)
timezone: str | None = Field(default=None, max_length=64)
payload: dict = Field(default_factory=dict, sa_type=JSON)
diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py
index 6e53e751eb..1e6f01a6d5 100644
--- a/astrbot/core/skills/skill_manager.py
+++ b/astrbot/core/skills/skill_manager.py
@@ -62,6 +62,7 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
# Based on openai/codex
return (
"## Skills\n"
+ "You have many useful skills that can help you accomplish various tasks.\n"
"A skill is a set of local instructions stored in a `SKILL.md` file.\n"
"### Available skills\n"
f"{skills_block}\n"
@@ -69,21 +70,21 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
"\n"
"- Discovery: The list above shows all skills available in this session. Full instructions live in the referenced `SKILL.md`.\n"
"- Trigger rules: Use a skill if the user names it or the task matches its description. Do not carry skills across turns unless re-mentioned\n"
- "- Unavailable: If a skill is missing or unreadable, say so and fallback.\n"
"### How to use a skill (progressive disclosure):\n"
- " 1) After deciding to use a skill, open its `SKILL.md` and read only what is necessary to follow the workflow.\n"
- " 2) Load only directly referenced files, DO NOT bulk-load everything.\n"
- " 3) If `scripts/` exist, prefer running or patching them instead of retyping large blocks of code.\n"
- " 4) If `assets/` or templates exist, reuse them rather than recreating everything from scratch.\n"
+ " 0) Mandatory grounding: Before using any skill, you MUST inspect its `SKILL.md` using shell tools"
+ " (e.g., `cat`, `head`, `sed`, `awk`, `grep`). Do not rely on assumptions or memory.\n"
+ " 1) Load only directly referenced files, DO NOT bulk-load everything.\n"
+ " 2) If `scripts/` exist, prefer running or patching them instead of retyping large blocks of code.\n"
+ " 3) If `assets/` or templates exist, reuse them rather than recreating everything from scratch.\n"
"- Coordination:\n"
" - If multiple skills apply, choose the minimal set that covers the request and state the order in which you will use them.\n"
" - Announce which skill(s) you are using and why (one short line). If you skip an obvious skill, explain why.\n"
" - Prefer to use `astrbot_*` tools to perform skills that need to run scripts.\n"
"- Context hygiene:\n"
- " - Keep context small: summarize long sections instead of pasting them, and load extra files only when necessary.\n"
" - Avoid deep reference chasing: unless blocked, open only files that are directly linked from `SKILL.md`.\n"
- " - When variants exist (frameworks, providers, domains), select only the relevant reference file(s) and note that choice.\n"
- "- Failure handling: If a skill cannot be applied, state the issue and continue with the best alternative."
+ "- Failure handling: If a skill cannot be applied, state the issue and continue with the best alternative.\n"
+ "### Example\n"
+ "When you decided to use a skill, use shell tool to read its `SKILL.md`, e.g., `head -40 skills/code_formatter/SKILL.md`, and you can increase or decrease the number of lines as needed.\n"
)
diff --git a/astrbot/core/tools/cron_tools.py b/astrbot/core/tools/cron_tools.py
index c4259aebd9..a605e8e77e 100644
--- a/astrbot/core/tools/cron_tools.py
+++ b/astrbot/core/tools/cron_tools.py
@@ -62,7 +62,7 @@ async def call(
next_run = job.next_run_time
return (
f"Scheduled future task {job.job_id} ({job.name}) with expression '{cron_expression}'. "
- f"Your future agent will wake at: {next_run}"
+ f"You will be awakened at: {next_run}"
)
diff --git a/astrbot/dashboard/routes/cron.py b/astrbot/dashboard/routes/cron.py
index df3110770f..80c6926840 100644
--- a/astrbot/dashboard/routes/cron.py
+++ b/astrbot/dashboard/routes/cron.py
@@ -28,6 +28,11 @@ def _serialize_job(self, job):
for k in ["created_at", "updated_at", "last_run_at", "next_run_time"]:
if isinstance(data.get(k), datetime):
data[k] = data[k].isoformat()
+ # expose note explicitly for UI (prefer payload.note then description)
+ payload = data.get("payload") or {}
+ data["note"] = payload.get("note") or data.get("description") or ""
+ # status is internal; hide to avoid implying one-time completion for recurring jobs
+ data.pop("status", None)
return data
async def list_jobs(self):
diff --git a/dashboard/src/i18n/locales/en-US/core/navigation.json b/dashboard/src/i18n/locales/en-US/core/navigation.json
index 86828377a4..ada9315df8 100644
--- a/dashboard/src/i18n/locales/en-US/core/navigation.json
+++ b/dashboard/src/i18n/locales/en-US/core/navigation.json
@@ -8,7 +8,7 @@
"toolUse": "MCP Tools",
"config": "Config",
"chat": "Chat",
- "cron": "Cron Jobs",
+ "cron": "Future Tasks",
"extension": "Extensions",
"conversation": "Conversations",
"sessionManagement": "Custom Rules",
diff --git a/dashboard/src/i18n/locales/zh-CN/core/navigation.json b/dashboard/src/i18n/locales/zh-CN/core/navigation.json
index cfeb426811..58b5c81d5b 100644
--- a/dashboard/src/i18n/locales/zh-CN/core/navigation.json
+++ b/dashboard/src/i18n/locales/zh-CN/core/navigation.json
@@ -9,7 +9,7 @@
"extension": "插件",
"config": "配置文件",
"chat": "聊天",
- "cron": "定时任务",
+ "cron": "未来任务",
"conversation": "对话数据",
"sessionManagement": "自定义规则",
"console": "平台日志",
diff --git a/dashboard/src/views/CronJobPage.vue b/dashboard/src/views/CronJobPage.vue
index ac205bb0ff..08c9e87034 100644
--- a/dashboard/src/views/CronJobPage.vue
+++ b/dashboard/src/views/CronJobPage.vue
@@ -2,58 +2,21 @@
-
Cron Job 管理
-
查看、创建与管理定时任务(ActiveAgent & 后台任务)。
+
未来任务管理
+
查看给 AstrBot 布置的未来任务。AstrBot 将会被自动唤醒、执行任务,然后将结果告知任务布置方。
刷新
-
-
- 新建主动型 Agent 定时任务
-
-
-
-
-
-
- 使用标准 5 段 Cron,例:0 8 * * * 表示每天 8:00。
-
-
-
- 从聊天侧栏或 Session 管理中复制 unified_msg_origin。
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 创建任务
-
-
-
-
-
- 暂无定时任务。
+ 暂无任务。
{{ item.timezone || 'local' }}
{{ formatTime(item.next_run_time) }}
-
- {{ item.status }}
-
+ {{ formatTime(item.last_run_at) }}
+ {{ item.note || '—' }}
([])
-const form = ref({
- name: 'active_agent_task',
- cron_expression: '',
- session: '',
- note: '',
- persona_id: '',
- provider_id: '',
- timezone: '',
- enabled: true
-})
-
const snackbar = ref({ show: false, message: '', color: 'success' })
const headers = [
@@ -127,7 +77,8 @@ const headers = [
{ title: '类型', key: 'type', width: 110 },
{ title: 'Cron', key: 'cron_expression', minWidth: 160 },
{ title: '下一次执行', key: 'next_run_time', minWidth: 160 },
- { title: '状态', key: 'status', width: 120 },
+ { title: '最近执行', key: 'last_run_at', minWidth: 160 },
+ { title: '说明', key: 'note', minWidth: 220 },
{ title: '操作', key: 'actions', width: 160, sortable: false }
]
@@ -144,19 +95,6 @@ function formatTime(val: any): string {
}
}
-function statusColor(status: string) {
- switch ((status || '').toLowerCase()) {
- case 'running':
- return 'blue'
- case 'failed':
- return 'error'
- case 'completed':
- return 'success'
- default:
- return 'secondary'
- }
-}
-
async function loadJobs() {
loading.value = true
try {
@@ -173,28 +111,6 @@ async function loadJobs() {
}
}
-async function createJob() {
- if (!form.value.cron_expression || !form.value.session || !form.value.note) {
- toast('请填写 cron、session 和说明', 'warning')
- return
- }
- saving.value = true
- try {
- const payload = { ...form.value, job_type: 'active_agent' }
- const res = await axios.post('/api/cron/jobs', payload)
- if (res.data.status === 'ok') {
- toast('创建成功')
- await loadJobs()
- } else {
- toast(res.data.message || '创建失败', 'error')
- }
- } catch (e: any) {
- toast(e?.response?.data?.message || '创建失败', 'error')
- } finally {
- saving.value = false
- }
-}
-
async function toggleJob(job: any) {
try {
const res = await axios.patch(`/api/cron/jobs/${job.job_id}`, { enabled: job.enabled })
From bddf7b8623ab0b079fefa3987dd28fc0cf301961 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sun, 1 Feb 2026 16:49:10 +0800
Subject: [PATCH 14/24] feat: add proactive messaging support and enhance
message handling in SendMessageToUserTool
---
astrbot/core/astr_main_agent.py | 6 +
astrbot/core/astr_main_agent_resources.py | 134 ++++++++++++++----
astrbot/core/platform/platform_metadata.py | 2 +
.../sources/dingtalk/dingtalk_adapter.py | 1 +
.../qqofficial/qqofficial_platform_adapter.py | 1 +
.../qqofficial_webhook/qo_webhook_adapter.py | 1 +
.../platform/sources/wecom/wecom_adapter.py | 1 +
.../sources/wecom_ai_bot/wecomai_adapter.py | 1 +
.../weixin_offacc_adapter.py | 1 +
9 files changed, 120 insertions(+), 28 deletions(-)
diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
index 1695560474..86596d43cc 100644
--- a/astrbot/core/astr_main_agent.py
+++ b/astrbot/core/astr_main_agent.py
@@ -33,6 +33,7 @@
SANDBOX_MODE_PROMPT,
TOOL_CALL_PROMPT,
TOOL_CALL_PROMPT_SKILLS_LIKE_MODE,
+ SEND_MESSAGE_TO_USER_TOOL,
retrieve_knowledge_base,
)
from astrbot.core.conversation_mgr import Conversation
@@ -923,6 +924,11 @@ async def build_main_agent(
if config.add_cron_tools:
_proactive_cron_job_tools(req)
+ if event.platform_meta.support_proactive_message:
+ if req.func_tool is None:
+ req.func_tool = ToolSet()
+ req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL)
+
if provider.provider_config.get("max_context_tokens", 0) <= 0:
model = provider.get_model()
if model_info := LLM_METADATAS.get(model):
diff --git a/astrbot/core/astr_main_agent_resources.py b/astrbot/core/astr_main_agent_resources.py
index 779f0d0c3e..ba6fc2059d 100644
--- a/astrbot/core/astr_main_agent_resources.py
+++ b/astrbot/core/astr_main_agent_resources.py
@@ -1,4 +1,5 @@
import base64
+import os
from pydantic import Field
from pydantic.dataclasses import dataclass
@@ -172,46 +173,123 @@ async def call(
@dataclass
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
name: str = "send_message_to_user"
- description: str = (
- "Send a short, proactive message to the user. "
- "Use this to deliver scheduled/background task results or important updates without waiting for a new user prompt."
- )
+ description: str = "Directly send message to the user. Only use this tool when you need to proactively message the user. Otherwise you can directly output the reply in the conversation."
+
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
- "message": {
- "type": "string",
- "description": "What you want to tell the user.",
- },
- "image_path": {
- "type": "string",
- "description": "Optional. Send an image to the user by specifying the file path. Use an absolute path when possible; otherwise, ensure the path is relative to `data/`.",
- },
- "session": {
- "type": "string",
- "description": "Optional target session in format platform_id:message_type:session_id. Defaults to current session.",
+ "messages": {
+ "type": "array",
+ "description": "An ordered list of message components to send. `mention_user` type can be used to mention the user.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": (
+ "Component type. One of: "
+ "plain, image, record, file, mention_user"
+ ),
+ },
+ "text": {
+ "type": "string",
+ "description": "Text content for `plain` type.",
+ },
+ "path": {
+ "type": "string",
+ "description": "File path for `image`, `record`, or `file` types.",
+ },
+ "url": {
+ "type": "string",
+ "description": "URL for `image`, `record`, or `file` types.",
+ },
+ "mention_user_id": {
+ "type": "string",
+ "description": "User ID to mention for `mention_user` type.",
+ },
+ },
+ "required": ["type"],
+ },
},
},
+ "required": ["messages"],
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> ToolExecResult:
- message = str(kwargs.get("message", "")).strip()
- image_path = kwargs.get("image_path")
session = kwargs.get("session") or context.context.event.unified_msg_origin
-
- if not message and not image_path:
- return "error: message is empty."
-
- comps: list[Comp.BaseMessageComponent] = []
-
- if message:
- comps.append(Comp.Plain(text=message))
- if image_path:
- comps.append(Comp.Image.fromFileSystem(path=image_path))
+ messages = kwargs.get("messages")
+
+ if not isinstance(messages, list) or not messages:
+ return "error: messages parameter is empty or invalid."
+
+ components: list[Comp.BaseMessageComponent] = []
+
+ for idx, msg in enumerate(messages):
+ if not isinstance(msg, dict):
+ return f"error: messages[{idx}] should be an object."
+
+ msg_type = str(msg.get("type", "")).lower()
+ if not msg_type:
+ return f"error: messages[{idx}].type is required."
+
+ try:
+ if msg_type == "plain":
+ text = str(msg.get("text", "")).strip()
+ if not text:
+ return f"error: messages[{idx}].text is required for plain component."
+ components.append(Comp.Plain(text=text))
+ elif msg_type == "image":
+ path = msg.get("path")
+ url = msg.get("url")
+ if path:
+ components.append(Comp.Image.fromFileSystem(path=path))
+ elif url:
+ components.append(Comp.Image.fromURL(url=url))
+ else:
+ return f"error: messages[{idx}] must include path or url for image component."
+ elif msg_type == "record":
+ path = msg.get("path")
+ url = msg.get("url")
+ if path:
+ components.append(Comp.Record.fromFileSystem(path=path))
+ elif url:
+ components.append(Comp.Record.fromURL(url=url))
+ else:
+ return f"error: messages[{idx}] must include path or url for record component."
+ elif msg_type == "file":
+ path = msg.get("path")
+ url = msg.get("url")
+ name = (
+ msg.get("text")
+ or (os.path.basename(path) if path else "")
+ or (os.path.basename(url) if url else "")
+ or "file"
+ )
+ if path:
+ components.append(Comp.File(name=name, file=path))
+ elif url:
+ components.append(Comp.File(name=name, url=url))
+ else:
+ return f"error: messages[{idx}] must include path or url for file component."
+ elif msg_type == "mention_user":
+ mention_user_id = msg.get("mention_user_id")
+ if not mention_user_id:
+ return f"error: messages[{idx}].mention_user_id is required for mention_user component."
+ components.append(
+ Comp.At(
+ qq=mention_user_id,
+ ),
+ )
+ else:
+ return (
+ f"error: unsupported message type '{msg_type}' at index {idx}."
+ )
+ except Exception as exc: # 捕获组件构造异常,避免直接抛出
+ return f"error: failed to build messages[{idx}] component: {exc}"
try:
target_session = (
@@ -224,7 +302,7 @@ async def call(
await context.context.context.send_message(
target_session,
- MessageChain(chain=comps),
+ MessageChain(chain=components),
)
return f"Message sent to session {target_session}"
diff --git a/astrbot/core/platform/platform_metadata.py b/astrbot/core/platform/platform_metadata.py
index 06455aac43..b5f11ca15c 100644
--- a/astrbot/core/platform/platform_metadata.py
+++ b/astrbot/core/platform/platform_metadata.py
@@ -19,3 +19,5 @@ class PlatformMetadata:
support_streaming_message: bool = True
"""平台是否支持真实流式传输"""
+ support_proactive_message: bool = True
+ """平台是否支持主动消息推送(非用户触发)"""
diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py
index e73f724cac..8c93ab40f8 100644
--- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py
+++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py
@@ -99,6 +99,7 @@ def meta(self) -> PlatformMetadata:
description="钉钉机器人官方 API 适配器",
id=cast(str, self.config.get("id")),
support_streaming_message=True,
+ support_proactive_message=False,
)
async def create_message_card(
diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py
index 7de535fbff..6f1164faf1 100644
--- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py
+++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py
@@ -136,6 +136,7 @@ def meta(self) -> PlatformMetadata:
name="qq_official",
description="QQ 机器人官方 API 适配器",
id=cast(str, self.config.get("id")),
+ support_proactive_message=False,
)
@staticmethod
diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py
index 80ed34245f..af160f1b5c 100644
--- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py
+++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py
@@ -118,6 +118,7 @@ def meta(self) -> PlatformMetadata:
name="qq_official_webhook",
description="QQ 机器人官方 API 适配器",
id=cast(str, self.config.get("id")),
+ support_proactive_message=False,
)
async def run(self):
diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py
index 44ed751171..adc24578fd 100644
--- a/astrbot/core/platform/sources/wecom/wecom_adapter.py
+++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py
@@ -224,6 +224,7 @@ def meta(self) -> PlatformMetadata:
"wecom 适配器",
id=self.config.get("id", "wecom"),
support_streaming_message=False,
+ support_proactive_message=False,
)
@override
diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py
index 70581e7ea3..57da5176ba 100644
--- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py
+++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py
@@ -128,6 +128,7 @@ def __init__(
name="wecom_ai_bot",
description="企业微信智能机器人适配器,支持 HTTP 回调接收消息",
id=self.config.get("id", "wecom_ai_bot"),
+ support_proactive_message=False,
)
# 初始化 API 客户端
diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py
index 2828c03929..a38952127e 100644
--- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py
+++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py
@@ -228,6 +228,7 @@ def meta(self) -> PlatformMetadata:
"微信公众平台 适配器",
id=self.config.get("id", "weixin_official_account"),
support_streaming_message=False,
+ support_proactive_message=False,
)
@override
From 193676012f43f71f3781f67bbe87a51d95114979 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sun, 1 Feb 2026 17:42:08 +0800
Subject: [PATCH 15/24] feat: implement history persistence for agent
interactions and enhance cron job permission handling
---
astrbot/core/astr_agent_tool_exec.py | 19 +++++++++++++++++
astrbot/core/astr_main_agent.py | 2 +-
astrbot/core/cron/manager.py | 32 ++++++++++++++++++++++++++--
astrbot/core/db/po.py | 4 +---
astrbot/core/utils/history_saver.py | 31 +++++++++++++++++++++++++++
5 files changed, 82 insertions(+), 6 deletions(-)
create mode 100644 astrbot/core/utils/history_saver.py
diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py
index 523f917eb2..460cab3324 100644
--- a/astrbot/core/astr_agent_tool_exec.py
+++ b/astrbot/core/astr_agent_tool_exec.py
@@ -28,6 +28,7 @@
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.provider.entites import ProviderRequest
from astrbot.core.provider.register import llm_tools
+from astrbot.core.utils.history_saver import persist_agent_history
class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
@@ -199,6 +200,7 @@ async def _execute_background(
extras=extras,
message_type=session.message_type,
)
+ cron_event.role = event.role
config = MainAgentBuildConfig(tool_call_timeout=3600)
req = ProviderRequest()
@@ -221,6 +223,7 @@ async def _execute_background(
req.prompt = (
"Proceed according to your system instructions. "
"Output using same language as previous conversation."
+ " After completing your task, summarize and output your actions and results."
)
if not req.func_tool:
req.func_tool = ToolSet()
@@ -238,6 +241,22 @@ async def _execute_background(
# agent will send message to user via using tools
pass
llm_resp = runner.get_final_llm_resp()
+ task_meta = extras.get("background_task_result", {})
+ summary_note = (
+ f"[BackgroundTask] {task_meta.get('tool_name', tool.name)} "
+ f"(task_id={task_meta.get('task_id', task_id)}) finished. "
+ f"Result: {task_meta.get('result') or result_text or 'no content'}"
+ )
+ if llm_resp and llm_resp.completion_text:
+ summary_note += (
+ f"I finished the task, here is the result: {llm_resp.completion_text}"
+ )
+ await persist_agent_history(
+ ctx.conversation_manager,
+ event=cron_event,
+ req=req,
+ summary_note=summary_note,
+ )
if not llm_resp:
logger.warning("background task agent got no response")
return
diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
index 86596d43cc..41aa0fe42c 100644
--- a/astrbot/core/astr_main_agent.py
+++ b/astrbot/core/astr_main_agent.py
@@ -31,9 +31,9 @@
LOCAL_PYTHON_TOOL,
PYTHON_TOOL,
SANDBOX_MODE_PROMPT,
+ SEND_MESSAGE_TO_USER_TOOL,
TOOL_CALL_PROMPT,
TOOL_CALL_PROMPT_SKILLS_LIKE_MODE,
- SEND_MESSAGE_TO_USER_TOOL,
retrieve_knowledge_base,
)
from astrbot.core.conversation_mgr import Conversation
diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py
index 06e88649e7..9401e551d4 100644
--- a/astrbot/core/cron/manager.py
+++ b/astrbot/core/cron/manager.py
@@ -15,6 +15,7 @@
from astrbot.core.db.po import CronJob
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.provider.entites import ProviderRequest
+from astrbot.core.utils.history_saver import persist_agent_history
if TYPE_CHECKING:
from astrbot.core.star.context import Context
@@ -182,7 +183,7 @@ async def _run_job(self, job_id: str):
if job.job_type == "basic":
await self._run_basic_job(job)
elif job.job_type == "active_agent":
- await self._run_active_agent_job(job)
+ await self._run_active_agent_job(job, start_time=start_time)
else:
raise ValueError(f"Unknown cron job type: {job.job_type}")
except Exception as e: # noqa: BLE001
@@ -208,7 +209,7 @@ async def _run_basic_job(self, job: CronJob):
if asyncio.iscoroutine(result):
await result
- async def _run_active_agent_job(self, job: CronJob):
+ async def _run_active_agent_job(self, job: CronJob, start_time: datetime):
payload = job.payload or {}
session_str = payload.get("session")
if not session_str:
@@ -222,6 +223,7 @@ async def _run_active_agent_job(self, job: CronJob):
"type": job.job_type,
"description": job.description,
"note": note,
+ "run_started_at": start_time.isoformat(),
},
"cron_payload": payload,
}
@@ -268,6 +270,15 @@ async def _woke_main_agent(
message_type=session.message_type,
)
+ # judge user's role
+ umo = cron_event.unified_msg_origin
+ cfg = self.ctx.get_config(umo=umo)
+ admin_ids = cfg.get("admins_id", [])
+ if admin_ids:
+ cron_event.role = (
+ "admin" if cron_event.get_sender_id() in admin_ids else "member"
+ )
+
config = MainAgentBuildConfig(
tool_call_timeout=3600,
llm_safety_mode=False,
@@ -295,6 +306,7 @@ async def _woke_main_agent(
"You are now responding to a scheduled task"
"Proceed according to your system instructions. "
"Output using same language as previous conversation."
+ "After completing your task, summarize and output your actions and results."
)
if not req.func_tool:
req.func_tool = ToolSet()
@@ -312,6 +324,22 @@ async def _woke_main_agent(
# agent will send message to user via using tools
pass
llm_resp = runner.get_final_llm_resp()
+ cron_meta = extras.get("cron_job", {}) if extras else {}
+ summary_note = (
+ f"[CronJob] {cron_meta.get('name') or cron_meta.get('id', 'unknown')}: {cron_meta.get('description', '')} "
+ f" triggered at {cron_meta.get('run_started_at', 'unknown time')}, "
+ )
+ if llm_resp and llm_resp.role == "assistant":
+ summary_note += (
+ f"I finished this job, here is the result: {llm_resp.completion_text}"
+ )
+
+ await persist_agent_history(
+ self.ctx.conversation_manager,
+ event=cron_event,
+ req=req,
+ summary_note=summary_note,
+ )
if not llm_resp:
logger.warning("Cron job agent got no response")
return
diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py
index d676fe8457..a63e5ea1eb 100644
--- a/astrbot/core/db/po.py
+++ b/astrbot/core/db/po.py
@@ -157,9 +157,7 @@ class CronJob(TimestampMixin, SQLModel, table=True):
)
name: str = Field(max_length=255, nullable=False)
description: str | None = Field(default=None, sa_type=Text)
- job_type: str = Field(
- max_length=32, nullable=False
- ) # basic | active_agent
+ job_type: str = Field(max_length=32, nullable=False) # basic | active_agent
cron_expression: str | None = Field(default=None, max_length=255)
timezone: str | None = Field(default=None, max_length=64)
payload: dict = Field(default_factory=dict, sa_type=JSON)
diff --git a/astrbot/core/utils/history_saver.py b/astrbot/core/utils/history_saver.py
new file mode 100644
index 0000000000..840d3f1871
--- /dev/null
+++ b/astrbot/core/utils/history_saver.py
@@ -0,0 +1,31 @@
+import json
+
+from astrbot import logger
+from astrbot.core.conversation_mgr import ConversationManager
+from astrbot.core.platform.astr_message_event import AstrMessageEvent
+from astrbot.core.provider.entities import ProviderRequest
+
+
+async def persist_agent_history(
+ conversation_manager: ConversationManager,
+ *,
+ event: AstrMessageEvent,
+ req: ProviderRequest,
+ summary_note: str,
+) -> None:
+ """Persist agent interaction into conversation history."""
+ if not req or not req.conversation:
+ return
+
+ history = []
+ try:
+ history = json.loads(req.conversation.history or "[]")
+ except Exception as exc: # noqa: BLE001
+ logger.warning("Failed to parse conversation history: %s", exc)
+ history.append({"role": "user", "content": "Output your last task result below."})
+ history.append({"role": "assistant", "content": summary_note})
+ await conversation_manager.update_conversation(
+ event.unified_msg_origin,
+ req.conversation.cid,
+ history=history,
+ )
From cd6cb1d60cb06f2ef1b55d87aa4a33465a28dc5f Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sun, 1 Feb 2026 17:50:29 +0800
Subject: [PATCH 16/24] chore: remove reminder
---
astrbot/builtin_stars/reminder/main.py | 266 -------------------
astrbot/builtin_stars/reminder/metadata.yaml | 4 -
2 files changed, 270 deletions(-)
delete mode 100644 astrbot/builtin_stars/reminder/main.py
delete mode 100644 astrbot/builtin_stars/reminder/metadata.yaml
diff --git a/astrbot/builtin_stars/reminder/main.py b/astrbot/builtin_stars/reminder/main.py
deleted file mode 100644
index 62af7ae56b..0000000000
--- a/astrbot/builtin_stars/reminder/main.py
+++ /dev/null
@@ -1,266 +0,0 @@
-import datetime
-import json
-import os
-import uuid
-import zoneinfo
-
-from apscheduler.schedulers.asyncio import AsyncIOScheduler
-from apscheduler.triggers.cron import CronTrigger
-
-from astrbot.api import llm_tool, logger, star
-from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
-from astrbot.core.utils.astrbot_path import get_astrbot_data_path
-
-
-class Main(star.Star):
- """使用 LLM 待办提醒。只需对 LLM 说想要提醒的事情和时间即可。比如:`之后每天这个时候都提醒我做多邻国`"""
-
- def __init__(self, context: star.Context) -> None:
- self.context = context
- self.timezone = self.context.get_config().get("timezone")
- if not self.timezone:
- self.timezone = None
- try:
- self.timezone = zoneinfo.ZoneInfo(self.timezone) if self.timezone else None
- except Exception as e:
- logger.error(f"时区设置错误: {e}, 使用本地时区")
- self.timezone = None
- self.scheduler = AsyncIOScheduler(timezone=self.timezone)
-
- # set and load config
- reminder_file = os.path.join(get_astrbot_data_path(), "astrbot-reminder.json")
- if not os.path.exists(reminder_file):
- with open(reminder_file, "w", encoding="utf-8") as f:
- f.write("{}")
- with open(reminder_file, encoding="utf-8") as f:
- self.reminder_data = json.load(f)
-
- self._init_scheduler()
- self.scheduler.start()
-
- def _init_scheduler(self):
- """Initialize the scheduler."""
- for group in self.reminder_data:
- for reminder in self.reminder_data[group]:
- if "id" not in reminder:
- id_ = str(uuid.uuid4())
- reminder["id"] = id_
- else:
- id_ = reminder["id"]
-
- if "datetime" in reminder:
- if self.check_is_outdated(reminder):
- continue
- self.scheduler.add_job(
- self._reminder_callback,
- id=id_,
- trigger="date",
- args=[group, reminder],
- run_date=datetime.datetime.strptime(
- reminder["datetime"],
- "%Y-%m-%d %H:%M",
- ),
- misfire_grace_time=60,
- )
- elif "cron" in reminder:
- trigger = CronTrigger(**self._parse_cron_expr(reminder["cron"]))
- self.scheduler.add_job(
- self._reminder_callback,
- trigger=trigger,
- id=id_,
- args=[group, reminder],
- misfire_grace_time=60,
- )
-
- def check_is_outdated(self, reminder: dict):
- """Check if the reminder is outdated."""
- if "datetime" in reminder:
- reminder_time = datetime.datetime.strptime(
- reminder["datetime"],
- "%Y-%m-%d %H:%M",
- ).replace(tzinfo=self.timezone)
- return reminder_time < datetime.datetime.now(self.timezone)
- return False
-
- async def _save_data(self):
- """Save the reminder data."""
- reminder_file = os.path.join(get_astrbot_data_path(), "astrbot-reminder.json")
- with open(reminder_file, "w", encoding="utf-8") as f:
- json.dump(self.reminder_data, f, ensure_ascii=False)
-
- def _parse_cron_expr(self, cron_expr: str):
- fields = cron_expr.split(" ")
- return {
- "minute": fields[0],
- "hour": fields[1],
- "day": fields[2],
- "month": fields[3],
- "day_of_week": fields[4],
- }
-
- @llm_tool("reminder")
- async def reminder_tool(
- self,
- event: AstrMessageEvent,
- text: str | None = None,
- datetime_str: str | None = None,
- cron_expression: str | None = None,
- human_readable_cron: str | None = None,
- ):
- """Call this function when user is asking for setting a reminder.
-
- Args:
- text(string): Must Required. The content of the reminder.
- datetime_str(string): Required when user's reminder is a single reminder. The datetime string of the reminder, Must format with %Y-%m-%d %H:%M
- cron_expression(string): Required when user's reminder is a repeated reminder. The cron expression of the reminder. Monday is 0 and Sunday is 6.
- human_readable_cron(string): Optional. The human readable cron expression of the reminder.
-
- """
- if event.get_platform_name() == "qq_official":
- yield event.plain_result("reminder 暂不支持 QQ 官方机器人。")
- return
-
- if event.unified_msg_origin not in self.reminder_data:
- self.reminder_data[event.unified_msg_origin] = []
-
- if not cron_expression and not datetime_str:
- raise ValueError(
- "The cron_expression and datetime_str cannot be both None.",
- )
- reminder_time = ""
-
- if not text:
- text = "未命名待办事项"
-
- if cron_expression:
- d = {
- "text": text,
- "cron": cron_expression,
- "cron_h": human_readable_cron,
- "id": str(uuid.uuid4()),
- }
- self.reminder_data[event.unified_msg_origin].append(d)
- trigger = CronTrigger(**self._parse_cron_expr(cron_expression))
- self.scheduler.add_job(
- self._reminder_callback,
- trigger,
- id=d["id"],
- misfire_grace_time=60,
- args=[event.unified_msg_origin, d],
- )
- if human_readable_cron:
- reminder_time = f"{human_readable_cron}(Cron: {cron_expression})"
- else:
- if datetime_str is None:
- raise ValueError("datetime_str cannot be None.")
- d = {"text": text, "datetime": datetime_str, "id": str(uuid.uuid4())}
- self.reminder_data[event.unified_msg_origin].append(d)
- datetime_scheduled = datetime.datetime.strptime(
- datetime_str,
- "%Y-%m-%d %H:%M",
- )
- self.scheduler.add_job(
- self._reminder_callback,
- "date",
- id=d["id"],
- args=[event.unified_msg_origin, d],
- run_date=datetime_scheduled,
- misfire_grace_time=60,
- )
- reminder_time = datetime_str
- await self._save_data()
- yield event.plain_result(
- "成功设置待办事项。\n内容: "
- + text
- + "\n时间: "
- + reminder_time
- + "\n\n使用 /reminder ls 查看所有待办事项。\n使用 /tool off reminder 关闭此功能。",
- )
-
- @filter.command_group("reminder")
- def reminder(self):
- """待办提醒"""
-
- async def get_upcoming_reminders(self, unified_msg_origin: str):
- """Get upcoming reminders."""
- reminders = self.reminder_data.get(unified_msg_origin, [])
- if not reminders:
- return []
- now = datetime.datetime.now(self.timezone)
- upcoming_reminders = [
- reminder
- for reminder in reminders
- if "datetime" not in reminder
- or datetime.datetime.strptime(
- reminder["datetime"],
- "%Y-%m-%d %H:%M",
- ).replace(tzinfo=self.timezone)
- >= now
- ]
- return upcoming_reminders
-
- @reminder.command("ls")
- async def reminder_ls(self, event: AstrMessageEvent):
- """List upcoming reminders."""
- reminders = await self.get_upcoming_reminders(event.unified_msg_origin)
- if not reminders:
- yield event.plain_result("没有正在进行的待办事项。")
- else:
- parts = ["正在进行的待办事项:\n"]
- for i, reminder in enumerate(reminders):
- time_ = reminder.get("datetime", "")
- if not time_:
- cron_expr = reminder.get("cron", "")
- time_ = reminder.get("cron_h", "") + f"(Cron: {cron_expr})"
- parts.append(f"{i + 1}. {reminder['text']} - {time_}\n")
- parts.append("\n使用 /reminder rm 删除待办事项。\n")
- reminder_str = "".join(parts)
- yield event.plain_result(reminder_str)
-
- @reminder.command("rm")
- async def reminder_rm(self, event: AstrMessageEvent, index: int):
- """Remove a reminder by index."""
- reminders = await self.get_upcoming_reminders(event.unified_msg_origin)
-
- if not reminders:
- yield event.plain_result("没有待办事项。")
- elif index < 1 or index > len(reminders):
- yield event.plain_result("索引越界。")
- else:
- reminder = reminders.pop(index - 1)
- job_id = reminder.get("id")
-
- # self.reminder_data[event.unified_msg_origin] = reminder
- users_reminders = self.reminder_data.get(event.unified_msg_origin, [])
- for i, r in enumerate(users_reminders):
- if r.get("id") == job_id:
- users_reminders.pop(i)
-
- try:
- self.scheduler.remove_job(job_id)
- except Exception as e:
- logger.error(f"Remove job error: {e}")
- yield event.plain_result(
- f"成功移除对应的待办事项。删除定时任务失败: {e!s} 可能需要重启 AstrBot 以取消该提醒任务。",
- )
- await self._save_data()
- yield event.plain_result("成功删除待办事项:\n" + reminder["text"])
-
- async def _reminder_callback(self, unified_msg_origin: str, d: dict):
- """The callback function of the reminder."""
- logger.info(f"Reminder Activated: {d['text']}, created by {unified_msg_origin}")
- await self.context.send_message(
- unified_msg_origin,
- MessageEventResult().message(
- "待办提醒: \n\n"
- + d["text"]
- + "\n时间: "
- + d.get("datetime", "")
- + d.get("cron_h", ""),
- ),
- )
-
- async def terminate(self):
- self.scheduler.shutdown()
- await self._save_data()
- logger.info("Reminder plugin terminated.")
diff --git a/astrbot/builtin_stars/reminder/metadata.yaml b/astrbot/builtin_stars/reminder/metadata.yaml
deleted file mode 100644
index fed835682c..0000000000
--- a/astrbot/builtin_stars/reminder/metadata.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-name: astrbot-reminder
-desc: 使用 LLM 待办提醒
-author: Soulter
-version: 0.0.1
\ No newline at end of file
From 9bf63354be48e1e0915fdac6d6827c1a1c23884f Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sun, 1 Feb 2026 17:58:30 +0800
Subject: [PATCH 17/24] feat: enhance UI for SubAgent and CronJob management
with beta indicators
---
dashboard/src/views/CronJobPage.vue | 35 ++++-----
dashboard/src/views/SubAgentPage.vue | 103 ++++++---------------------
2 files changed, 36 insertions(+), 102 deletions(-)
diff --git a/dashboard/src/views/CronJobPage.vue b/dashboard/src/views/CronJobPage.vue
index 08c9e87034..3c58c642c0 100644
--- a/dashboard/src/views/CronJobPage.vue
+++ b/dashboard/src/views/CronJobPage.vue
@@ -2,7 +2,10 @@
-
未来任务管理
+
+
未来任务管理
+ Beta
+
查看给 AstrBot 布置的未来任务。AstrBot 将会被自动唤醒、执行任务,然后将结果告知任务布置方。
@@ -18,14 +21,8 @@
暂无任务。
-
+
{{ item.name }}
{{ item.description }}
@@ -42,14 +39,8 @@
{{ item.note || '—' }}
-
+
删除
@@ -73,12 +64,12 @@ const jobs = ref([])
const snackbar = ref({ show: false, message: '', color: 'success' })
const headers = [
- { title: '名称', key: 'name', minWidth: 200 },
+ { title: '名称', key: 'name', minWidth: '200px' },
{ title: '类型', key: 'type', width: 110 },
- { title: 'Cron', key: 'cron_expression', minWidth: 160 },
- { title: '下一次执行', key: 'next_run_time', minWidth: 160 },
- { title: '最近执行', key: 'last_run_at', minWidth: 160 },
- { title: '说明', key: 'note', minWidth: 220 },
+ { title: 'Cron', key: 'cron_expression', minWidth: '160px' },
+ { title: '下一次执行', key: 'next_run_time', minWidth: '160px' },
+ { title: '最近执行', key: 'last_run_at', minWidth: '160px' },
+ { title: '说明', key: 'note', minWidth: '220px' },
{ title: '操作', key: 'actions', width: 160, sortable: false }
]
diff --git a/dashboard/src/views/SubAgentPage.vue b/dashboard/src/views/SubAgentPage.vue
index 5e407dfdf4..2aa404ac38 100644
--- a/dashboard/src/views/SubAgentPage.vue
+++ b/dashboard/src/views/SubAgentPage.vue
@@ -2,7 +2,10 @@
-
SubAgent 编排
+
+
SubAgent 编排
+ Beta
+
主 LLM 只负责聊天与分派(handoff),工具挂载在各个 SubAgent 上。
@@ -18,16 +21,8 @@
-
+
@@ -45,29 +40,17 @@
SubAgents
-
+
新增 SubAgent
-
+
-
+
{{ agent.enabled ? '启用' : '停用' }}
@@ -78,23 +61,12 @@
-
+
启用
-
+
删除
@@ -104,52 +76,24 @@
-
+
-
+
-
+
-
+
@@ -468,5 +412,4 @@ onMounted(() => {
.subagent-tools .v-field__input {
padding-right: 6px;
}
-
From 2213fb1ebfdc0129ac45b6ad3c7cb653711e162a Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sun, 1 Feb 2026 18:12:11 +0800
Subject: [PATCH 18/24] feat: add proactive messaging support in CronJobPage
and enhance file download tool with user notification option
---
astrbot/core/computer/tools/fs.py | 22 +++++++++------
astrbot/core/platform/platform.py | 9 ++++++
.../sources/webchat/webchat_adapter.py | 1 +
.../full/vertical-sidebar/sidebarItem.ts | 28 +++++++++----------
dashboard/src/views/CronJobPage.vue | 28 ++++++++++++++++++-
5 files changed, 65 insertions(+), 23 deletions(-)
diff --git a/astrbot/core/computer/tools/fs.py b/astrbot/core/computer/tools/fs.py
index b686229405..47f1307004 100644
--- a/astrbot/core/computer/tools/fs.py
+++ b/astrbot/core/computer/tools/fs.py
@@ -144,7 +144,11 @@ class FileDownloadTool(FunctionTool):
"remote_path": {
"type": "string",
"description": "The path of the file in the sandbox to download.",
- }
+ },
+ "also_send_to_user": {
+ "type": "boolean",
+ "description": "Whether to also send the downloaded file to the user via message. Defaults to true.",
+ },
},
"required": ["remote_path"],
}
@@ -154,6 +158,7 @@ async def call(
self,
context: ContextWrapper[AstrAgentContext],
remote_path: str,
+ also_send_to_user: bool = True,
) -> ToolExecResult:
sb = await get_booter(
context.context.context,
@@ -168,13 +173,14 @@ async def call(
await sb.download_file(remote_path, local_path)
logger.info(f"File {remote_path} downloaded from sandbox to {local_path}")
- try:
- name = os.path.basename(local_path)
- await context.context.event.send(
- MessageChain(chain=[File(name=name, file=local_path)])
- )
- except Exception as e:
- logger.error(f"Error sending file message: {e}")
+ if also_send_to_user:
+ try:
+ name = os.path.basename(local_path)
+ await context.context.event.send(
+ MessageChain(chain=[File(name=name, file=local_path)])
+ )
+ except Exception as e:
+ logger.error(f"Error sending file message: {e}")
# remove
try:
diff --git a/astrbot/core/platform/platform.py b/astrbot/core/platform/platform.py
index c2e55fb63f..8592273d18 100644
--- a/astrbot/core/platform/platform.py
+++ b/astrbot/core/platform/platform.py
@@ -90,6 +90,14 @@ def unified_webhook(self) -> bool:
def get_stats(self) -> dict:
"""获取平台统计信息"""
meta = self.meta()
+ meta_info = {
+ "id": meta.id,
+ "name": meta.name,
+ "display_name": meta.adapter_display_name or meta.name,
+ "description": meta.description,
+ "support_streaming_message": meta.support_streaming_message,
+ "support_proactive_message": meta.support_proactive_message,
+ }
return {
"id": meta.id or self.config.get("id"),
"type": meta.name,
@@ -105,6 +113,7 @@ def get_stats(self) -> dict:
if self.last_error
else None,
"unified_webhook": self.unified_webhook(),
+ "meta": meta_info,
}
@abc.abstractmethod
diff --git a/astrbot/core/platform/sources/webchat/webchat_adapter.py b/astrbot/core/platform/sources/webchat/webchat_adapter.py
index 36a451fbdd..316c95d814 100644
--- a/astrbot/core/platform/sources/webchat/webchat_adapter.py
+++ b/astrbot/core/platform/sources/webchat/webchat_adapter.py
@@ -86,6 +86,7 @@ def __init__(
name="webchat",
description="webchat",
id="webchat",
+ support_proactive_message=False,
)
async def send_by_session(
diff --git a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts
index e75ddb6614..fce2c8efc8 100644
--- a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts
+++ b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts
@@ -43,19 +43,24 @@ const sidebarItem: menu[] = [
icon: 'mdi-book-open-variant',
to: '/knowledge-base',
},
+ {
+ title: 'core.navigation.persona',
+ icon: 'mdi-heart',
+ to: '/persona'
+ },
{
title: 'core.navigation.groups.more',
icon: 'mdi-dots-horizontal',
children: [
{
- title: 'core.navigation.persona',
- icon: 'mdi-heart',
- to: '/persona'
+ title: 'core.navigation.conversation',
+ icon: 'mdi-database',
+ to: '/conversation'
},
{
- title: 'core.navigation.subagent',
- icon: 'mdi-vector-link',
- to: '/subagent'
+ title: 'core.navigation.sessionManagement',
+ icon: 'mdi-pencil-ruler',
+ to: '/session-management'
},
{
title: 'core.navigation.cron',
@@ -63,14 +68,9 @@ const sidebarItem: menu[] = [
to: '/cron'
},
{
- title: 'core.navigation.conversation',
- icon: 'mdi-database',
- to: '/conversation'
- },
- {
- title: 'core.navigation.sessionManagement',
- icon: 'mdi-pencil-ruler',
- to: '/session-management'
+ title: 'core.navigation.subagent',
+ icon: 'mdi-vector-link',
+ to: '/subagent'
},
{
title: 'core.navigation.dashboard',
diff --git a/dashboard/src/views/CronJobPage.vue b/dashboard/src/views/CronJobPage.vue
index 3c58c642c0..23d2eb561f 100644
--- a/dashboard/src/views/CronJobPage.vue
+++ b/dashboard/src/views/CronJobPage.vue
@@ -6,7 +6,14 @@
未来任务管理
Beta
- 查看给 AstrBot 布置的未来任务。AstrBot 将会被自动唤醒、执行任务,然后将结果告知任务布置方。
+
+ 查看给 AstrBot 布置的未来任务。AstrBot 将会被自动唤醒、执行任务,然后将结果告知任务布置方。
+ 主动发送结果仅支持以下平台:
+
+ {{ proactivePlatforms.map((p) => `${p.display_name || p.name}(${p.id})`).join('、') }}
+
+ 暂无支持主动消息的平台,请在平台设置中开启。
+
刷新
@@ -60,6 +67,7 @@ import axios from 'axios'
const loading = ref(false)
const jobs = ref
([])
+const proactivePlatforms = ref<{ id: string; name: string; display_name?: string }[]>([])
const snackbar = ref({ show: false, message: '', color: 'success' })
@@ -102,6 +110,23 @@ async function loadJobs() {
}
}
+async function loadPlatforms() {
+ try {
+ const res = await axios.get('/api/platform/stats')
+ if (res.data.status === 'ok' && Array.isArray(res.data.data?.platforms)) {
+ proactivePlatforms.value = res.data.data.platforms
+ .filter((p: any) => p?.meta?.support_proactive_message)
+ .map((p: any) => ({
+ id: p?.id || p?.meta?.id || 'unknown',
+ name: p?.meta?.name || p?.type || '',
+ display_name: p?.meta?.display_name || p?.display_name
+ }))
+ }
+ } catch (e) {
+ // ignore platform fetch errors in UI; subtitle will show fallback
+ }
+}
+
async function toggleJob(job: any) {
try {
const res = await axios.patch(`/api/cron/jobs/${job.job_id}`, { enabled: job.enabled })
@@ -131,6 +156,7 @@ async function deleteJob(job: any) {
onMounted(() => {
loadJobs()
+ loadPlatforms()
})
From 313a6d8a248df549362aeea696db04a5b258c9e4 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sun, 1 Feb 2026 18:12:40 +0800
Subject: [PATCH 19/24] fix: improve error handling for temporary file removal
in FileDownloadTool
---
astrbot/core/computer/tools/fs.py | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/astrbot/core/computer/tools/fs.py b/astrbot/core/computer/tools/fs.py
index 47f1307004..b6823c1223 100644
--- a/astrbot/core/computer/tools/fs.py
+++ b/astrbot/core/computer/tools/fs.py
@@ -182,11 +182,11 @@ async def call(
except Exception as e:
logger.error(f"Error sending file message: {e}")
- # remove
- try:
- os.remove(local_path)
- except Exception as e:
- logger.error(f"Error removing temp file {local_path}: {e}")
+ # remove
+ try:
+ os.remove(local_path)
+ except Exception as e:
+ logger.error(f"Error removing temp file {local_path}: {e}")
return f"File downloaded successfully to {local_path}"
except Exception as e:
From bf587765de93036e973acc56af80d00338155280 Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sun, 1 Feb 2026 18:13:23 +0800
Subject: [PATCH 20/24] feat: enhance FileDownloadTool to confirm file removal
and successful download
---
astrbot/core/computer/tools/fs.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/astrbot/core/computer/tools/fs.py b/astrbot/core/computer/tools/fs.py
index b6823c1223..a1c0f848f4 100644
--- a/astrbot/core/computer/tools/fs.py
+++ b/astrbot/core/computer/tools/fs.py
@@ -187,6 +187,8 @@ async def call(
os.remove(local_path)
except Exception as e:
logger.error(f"Error removing temp file {local_path}: {e}")
+
+ return f"File downloaded successfully to {local_path} and sent to user. The file has been removed from local storage."
return f"File downloaded successfully to {local_path}"
except Exception as e:
From 3f8d8b5033ca54292a9bb1679a729193ef4c31ea Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sun, 1 Feb 2026 20:43:08 +0800
Subject: [PATCH 21/24] feat: integrate subagent orchestrator with
configuration options for tool management
---
astrbot/core/astr_main_agent.py | 43 +++++++++------------
astrbot/core/config/default.py | 8 ++--
astrbot/core/core_lifecycle.py | 8 ++--
astrbot/core/provider/func_tool_manager.py | 43 ---------------------
astrbot/core/star/context.py | 3 ++
astrbot/core/subagent_orchestrator.py | 19 ++-------
astrbot/dashboard/routes/subagent.py | 9 ++---
dashboard/src/views/SubAgentPage.vue | 45 +++++++++-------------
8 files changed, 54 insertions(+), 124 deletions(-)
diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
index 41aa0fe42c..211cce8e2b 100644
--- a/astrbot/core/astr_main_agent.py
+++ b/astrbot/core/astr_main_agent.py
@@ -324,11 +324,11 @@ async def _ensure_persona_and_skills(
tmgr = plugin_context.get_llm_tool_manager()
+ # sub agents integration
orch_cfg = plugin_context.get_config().get("subagent_orchestrator", {})
- if orch_cfg.get("main_enable", False):
- policy = str(orch_cfg.get("main_tools_policy", "handoff_only")).strip()
- if policy not in {"handoff_only", "unassigned_to_main"}:
- policy = "handoff_only"
+ so = plugin_context.subagent_orchestrator
+ if orch_cfg.get("main_enable", False) and so:
+ remove_dup = bool(orch_cfg.get("remove_main_duplicate_tools", False))
assigned_tools: set[str] = set()
agents = orch_cfg.get("agents", [])
@@ -368,22 +368,21 @@ async def _ensure_persona_and_skills(
if name:
assigned_tools.add(name)
- toolset = ToolSet()
- for tool in tmgr.func_list:
- if isinstance(tool, HandoffTool) and tool.active:
- toolset.add_tool(tool)
+ if req.func_tool is None:
+ toolset = ToolSet()
+ else:
+ toolset = req.func_tool
- if policy == "unassigned_to_main":
- for tool in tmgr.func_list:
- if not tool.active:
- continue
- if isinstance(tool, HandoffTool):
- continue
- if tool.handler_module_path == "core.subagent_orchestrator":
- continue
- if tool.name in assigned_tools:
- continue
- toolset.add_tool(tool)
+ # add subagent handoff tools
+ for tool in so.handoffs:
+ toolset.add_tool(tool)
+
+ # check duplicates
+ if remove_dup:
+ names = toolset.names()
+ for tool_name in assigned_tools:
+ if tool_name in names:
+ toolset.remove_tool(tool_name)
req.func_tool = toolset
@@ -394,12 +393,6 @@ async def _ensure_persona_and_skills(
).strip()
if router_prompt:
req.system_prompt += f"\n{router_prompt}\n"
- if policy == "unassigned_to_main":
- req.system_prompt += (
- "\n[Note: You may directly call the tools visible to the main LLM "
- "if they are not assigned to any subagent; otherwise prefer delegating "
- "to subagents via transfer_to_*.]\n"
- )
return
# inject toolset in the persona
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index 702316d2e3..a752dfc55c 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -126,13 +126,11 @@
},
# SubAgent orchestrator mode:
# - main_enable = False: disabled; main LLM mounts tools normally (persona selection).
- # - main_enable = True: enabled; main LLM tool mounting is controlled by main_tools_policy.
+ # - main_enable = True: enabled; main LLM will include handoff tools and can optionally
+ # remove tools that are duplicated on subagents via remove_main_duplicate_tools.
"subagent_orchestrator": {
"main_enable": False,
- # - handoff_only: main LLM only sees transfer_to_* tools (recommended default when enabled).
- # - unassigned_to_main: tools not assigned to any subagent are still mounted on main LLM.
- # - disabled: UI convenience value; ignored when main_enable is False.
- "main_tools_policy": "disabled",
+ "remove_main_duplicate_tools": False,
"router_system_prompt": (
"You are a task router. Your job is to chat naturally, recognize user intent, "
"and delegate work to the most suitable subagent using transfer_to_* tools. "
diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py
index f619b64afa..6b36cca0d3 100644
--- a/astrbot/core/core_lifecycle.py
+++ b/astrbot/core/core_lifecycle.py
@@ -55,8 +55,6 @@ def __init__(self, log_broker: LogBroker, db: BaseDatabase) -> None:
self.astrbot_config = astrbot_config # 初始化配置
self.db = db # 初始化数据库
- # Optional orchestrator that registers dynamic handoff tools (transfer_to_*)
- # from provider_settings.subagent_orchestrator.
self.subagent_orchestrator: SubAgentOrchestrator | None = None
self.cron_manager: CronJobManager | None = None
@@ -169,6 +167,9 @@ async def initialize(self) -> None:
# 初始化 CronJob 管理器
self.cron_manager = CronJobManager(self.db)
+ # Dynamic subagents (handoff tools) from config.
+ await self._init_or_reload_subagent_orchestrator()
+
# 初始化提供给插件的上下文
self.star_context = Context(
self.event_queue,
@@ -182,6 +183,7 @@ async def initialize(self) -> None:
self.astrbot_config_mgr,
self.kb_manager,
self.cron_manager,
+ self.subagent_orchestrator,
)
# 初始化插件管理器
@@ -208,8 +210,6 @@ async def initialize(self) -> None:
self.astrbot_config_mgr,
)
- # Dynamic subagents (handoff tools) from config.
- await self._init_or_reload_subagent_orchestrator()
# 记录启动时间
self.start_time = int(time.time())
diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py
index 93990d87c3..7aad86bddf 100644
--- a/astrbot/core/provider/func_tool_manager.py
+++ b/astrbot/core/provider/func_tool_manager.py
@@ -11,7 +11,6 @@
from astrbot import logger
from astrbot.core import sp
-from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.mcp_client import MCPClient, MCPTool
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@@ -180,48 +179,6 @@ def get_full_tool_set(self) -> ToolSet:
tool_set = ToolSet(self.func_list.copy())
return tool_set
- def sync_dynamic_handoff_tools(
- self,
- handoffs: list[HandoffTool],
- *,
- handler_module_path: str,
- ) -> None:
- """Sync dynamic transfer_to_* tools in-place.
-
- This removes any existing tools previously registered under the same
- handler_module_path and then registers the provided HandoffTool list.
-
- NOTE: add_func() stores a FunctionTool wrapper; for handoff tools we
- want to keep the real HandoffTool objects in func_list so other parts
- of the system can inspect agent/provider_id metadata.
- """
-
- # Remove previously registered dynamic handoff tools.
- self.func_list = [
- t for t in self.func_list if t.handler_module_path != handler_module_path
- ]
-
- for handoff in handoffs:
- handoff.handler_module_path = handler_module_path
-
- # Register tool (ensures the handler is reachable by name).
- self.add_func(
- name=handoff.name,
- func_args=[
- {
- "type": "string",
- "name": "input",
- "description": "Task input delegated from the main agent.",
- }
- ],
- desc=handoff.description,
- handler=handoff.handler,
- )
-
- # Replace wrapper with the actual HandoffTool instance.
- self.remove_func(handoff.name)
- self.func_list.append(handoff)
-
async def init_mcp_clients(self) -> None:
"""从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下:
```
diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py
index fee20640ae..ec7cbbe9ef 100644
--- a/astrbot/core/star/context.py
+++ b/astrbot/core/star/context.py
@@ -24,6 +24,7 @@
from astrbot.core.provider.entities import LLMResponse, ProviderRequest, ProviderType
from astrbot.core.provider.func_tool_manager import FunctionTool, FunctionToolManager
from astrbot.core.provider.manager import ProviderManager
+from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
from astrbot.core.provider.provider import (
EmbeddingProvider,
Provider,
@@ -67,6 +68,7 @@ def __init__(
astrbot_config_mgr: AstrBotConfigManager,
knowledge_base_manager: KnowledgeBaseManager,
cron_manager: CronJobManager,
+ subagent_orchestrator: SubAgentOrchestrator | None = None,
):
self._event_queue = event_queue
"""事件队列。消息平台通过事件队列传递消息事件。"""
@@ -90,6 +92,7 @@ def __init__(
"""知识库管理器"""
self.cron_manager = cron_manager
"""Cron job manager, initialized by core lifecycle."""
+ self.subagent_orchestrator = subagent_orchestrator
async def llm_generate(
self,
diff --git a/astrbot/core/subagent_orchestrator.py b/astrbot/core/subagent_orchestrator.py
index 1e9c9ea9e7..62ddc0fd3a 100644
--- a/astrbot/core/subagent_orchestrator.py
+++ b/astrbot/core/subagent_orchestrator.py
@@ -5,7 +5,6 @@
from astrbot import logger
from astrbot.core.agent.agent import Agent
from astrbot.core.agent.handoff import HandoffTool
-from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.persona_mgr import PersonaManager
from astrbot.core.provider.func_tool_manager import FunctionToolManager
@@ -20,17 +19,10 @@ class SubAgentOrchestrator:
def __init__(self, tool_mgr: FunctionToolManager, persona_mgr: PersonaManager):
self._tool_mgr = tool_mgr
self._persona_mgr = persona_mgr
+ self.handoffs: list[HandoffTool] = []
async def reload_from_config(self, cfg: dict[str, Any]) -> None:
- enabled = bool(cfg.get("main_enable", False))
-
- if not enabled:
- # Ensure any previous dynamic handoff tools are cleared.
- self._tool_mgr.sync_dynamic_handoff_tools(
- [],
- handler_module_path="core.subagent_orchestrator",
- )
- return
+ from astrbot.core.astr_agent_context import AstrAgentContext
agents = cfg.get("agents", [])
if not isinstance(agents, list):
@@ -98,10 +90,7 @@ async def reload_from_config(self, cfg: dict[str, Any]) -> None:
handoffs.append(handoff)
- self._tool_mgr.sync_dynamic_handoff_tools(
- handoffs,
- handler_module_path="core.subagent_orchestrator",
- )
-
for handoff in handoffs:
logger.info(f"Registered subagent handoff tool: {handoff.name}")
+
+ self.handoffs = handoffs
diff --git a/astrbot/dashboard/routes/subagent.py b/astrbot/dashboard/routes/subagent.py
index 997d01eb8c..e3d77f73ad 100644
--- a/astrbot/dashboard/routes/subagent.py
+++ b/astrbot/dashboard/routes/subagent.py
@@ -35,7 +35,7 @@ async def get_config(self):
if not isinstance(data, dict):
data = {
"main_enable": False,
- "main_tools_policy": "disabled",
+ "remove_main_duplicate_tools": False,
"agents": [],
}
@@ -49,10 +49,7 @@ async def get_config(self):
# Ensure required keys exist.
data.setdefault("main_enable", False)
- if "main_tools_policy" not in data:
- data["main_tools_policy"] = (
- "handoff_only" if data.get("main_enable", False) else "disabled"
- )
+ data.setdefault("remove_main_duplicate_tools", False)
data.setdefault("agents", [])
# Backward/forward compatibility: ensure each agent contains provider_id.
@@ -83,7 +80,7 @@ async def update_config(self):
# Reload dynamic handoff tools if orchestrator exists
orch = getattr(self.core_lifecycle, "subagent_orchestrator", None)
if orch is not None:
- orch.reload_from_config(data)
+ await orch.reload_from_config(data)
return jsonify(Response().ok(message="保存成功").__dict__)
except Exception as e:
diff --git a/dashboard/src/views/SubAgentPage.vue b/dashboard/src/views/SubAgentPage.vue
index 2aa404ac38..4474172fdc 100644
--- a/dashboard/src/views/SubAgentPage.vue
+++ b/dashboard/src/views/SubAgentPage.vue
@@ -20,21 +20,24 @@
-
-
+
+
+
+
+
-
+
不启动:SubAgent 关闭;主 LLM 按 persona 规则挂载工具(默认全部),并直接调用。
-
- 启动:SubAgent 可分派;未分配给任何 SubAgent 的工具仍挂载到主 LLM 上。
-
- 启动:仅 SubAgent;主 LLM 只保留 transfer_to_* 这类委派工具,不挂载其他工具。
+ 启动:主 LLM 会保留自身工具并挂载 transfer_to_* 委派工具。
+ 若开启“去重重复工具”,与 SubAgent 指定的工具重叠部分会从主 LLM 工具集中移除。
@@ -135,10 +138,9 @@ type SubAgentItem = {
provider_id?: string
}
-type MainMode = 'disabled' | 'unassigned_to_main' | 'handoff_only'
-
type SubAgentConfig = {
- main_mode: MainMode
+ main_enable: boolean
+ remove_main_duplicate_tools: boolean
agents: SubAgentItem[]
}
@@ -155,14 +157,9 @@ function toast(message: string, color: 'success' | 'error' | 'warning' = 'succes
snackbar.value = { show: true, message, color }
}
-const mainModes: Array<{ label: string; value: MainMode }> = [
- { label: '不启动:SubAgent 关闭(主 LLM 按 persona 挂载工具)', value: 'disabled' },
- { label: '启动:未分配工具仍挂载到主 LLM', value: 'unassigned_to_main' },
- { label: '启动:仅 SubAgent(主 LLM 仅 transfer_to_*)', value: 'handoff_only' }
-]
-
const cfg = ref
({
- main_mode: 'disabled',
+ main_enable: false,
+ remove_main_duplicate_tools: false,
agents: []
})
@@ -171,10 +168,7 @@ const personaLoading = ref(false)
function normalizeConfig(raw: any): SubAgentConfig {
const main_enable = !!raw?.main_enable
- const policy = (raw?.main_tools_policy ?? '').toString().trim()
- const main_mode: MainMode = !main_enable
- ? 'disabled'
- : (policy === 'unassigned_to_main' ? 'unassigned_to_main' : 'handoff_only')
+ const remove_main_duplicate_tools = !!raw?.remove_main_duplicate_tools
const agentsRaw = Array.isArray(raw?.agents) ? raw.agents : []
const agents: SubAgentItem[] = agentsRaw.map((a: any, i: number) => {
@@ -195,7 +189,7 @@ function normalizeConfig(raw: any): SubAgentConfig {
}
})
- return { main_mode, agents }
+ return { main_enable, remove_main_duplicate_tools, agents }
}
async function loadConfig() {
@@ -278,10 +272,9 @@ async function save() {
saving.value = true
try {
// Strip UI-only fields
- const mode = cfg.value.main_mode
const payload = {
- main_enable: mode !== 'disabled',
- main_tools_policy: mode,
+ main_enable: cfg.value.main_enable,
+ remove_main_duplicate_tools: cfg.value.remove_main_duplicate_tools,
agents: cfg.value.agents.map(a => ({
name: a.name,
persona_id: a.persona_id,
From f66edc8d458aea4eb396dd8eb0c3fe42eee5e0ea Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sun, 1 Feb 2026 22:04:30 +0800
Subject: [PATCH 22/24] feat: implement CronJob system with support for
one-time tasks and enhanced UI for task management
---
ASYNC_TASK_new.md | 18 ++++
astrbot/core/cron/manager.py | 38 +++++++--
astrbot/core/db/__init__.py | 2 +
astrbot/core/db/po.py | 1 +
astrbot/core/db/sqlite.py | 4 +
astrbot/core/tools/cron_tools.py | 53 +++++++++---
astrbot/dashboard/routes/cron.py | 45 +++++++---
dashboard/src/views/CronJobPage.vue | 122 +++++++++++++++++++++++++++-
8 files changed, 253 insertions(+), 30 deletions(-)
create mode 100644 ASYNC_TASK_new.md
diff --git a/ASYNC_TASK_new.md b/ASYNC_TASK_new.md
new file mode 100644
index 0000000000..fc5d5deefa
--- /dev/null
+++ b/ASYNC_TASK_new.md
@@ -0,0 +1,18 @@
+我需要让 Agent 能够在未来提醒自己去做某些事情,这样 Agent 能够主动地去完成一些任务,而不是等用户主动来下达命令。
+
+你需要实现一个 CronJob 系统,允许 Agent 创建未来任务,并且在未来的某个时间点自动触发这些任务的执行.
+
+CronJob 系统分为 BasicCronJob 和 ActiveAgentCronJob 两种类型。前者只是简单的提供一个定时任务功能(给插件用),而后者则允许 Agent 主动地去完成一些任务。BasicCronJob 不必多说,就是定时执行某个函数。对于 ActiveAgentCronJob,Agent 应该可以主动管理(比如通过Tool来管理)这些 CronJobs,当添加的时候,Agent 可以给 CronJob 捎一段文字,以说明未来的自己需要做什么事情。比如说,Agent 在听到用户 “每天早上都给我整理一份今日早报” 之后,应该可以创建 Cron Job,并且自己写脚本来完成这个任务,并且注册 cron job。Agent 给未来的自己捎去的信息应该只是呈现为一段文字,这样可以保持设计简约。当触发后, CronJobManager 会调用 MainAgent 的一轮循环,MainAgent 通过上下文知道这是一个定时任务触发的循环,从而执行相应的操作。
+
+此外,我还有一个需求,后台长任务。需要给当前的 FunctionTool 类增加一个属性,is_background_task: bool = False,插件可以通过这个属性来声明这是一个异步任务。这是为了解决一些 Tool 需要长时间运行的问题,比如 Deep Search tool 需要长时间搜索网页内容、Sub Agent 需要长时间运行来完成一个复杂任务。
+
+基于上面的讨论,我觉得,应该:
+
+1. 需要给当前的 FunctionTool 类增加一个属性is_background_task: bool = False,tool runner 在执行这个 tool 的时候,如果发现是后台任务,就不等待结果返回,而是直接返回一个任务 ID (已经创建成功提示)的结果,tool runner 在后台继续执行这个任务。当任务完成之后,任务的结果回传给 MainAgent(其实就是再执行一次 main agent loop,但是上下文应该是最新的),并且 MainAgent 此时应该有 send_message_to_user 的工具,通过这个工具可以选择是否主动通知用户任务完成的结果。
+2. 增加一个 CronJobManager 类,负责管理所有的定时任务。Agent 可以通过调用这个类的方法来创建、删除、修改定时任务。通过 cron expression 来定义触发条件。
+3. CronJobManager 除了管理普通的定时任务(比如插件可能有一些自己的定时任务),还有一种特殊的任务类型,就是上面提到的主动型 Agent 任务。用户提需求,MainAgent 选择性地调用 CronJobManager 的方法来创建这些任务,并且在任务触发时,CronJobManager 的回调就是执行 MainAgent 的一轮循环(需要加 send_message_to_user tool),MainAgent 通过上下文知道这是一个定时任务触发的循环,从而执行相应的操作。
+4. WebUI 需要增加 Cron Job 管理界面,用户可以在界面上查看、创建、修改、删除定时任务。对于主动型 Agent 任务,用户可以看到任务的描述、触发条件等信息。
+5. 除此之外,现在的代码中已经有了 subagent 的管理。WebUI 可以创建 SubAgent,但是还没写完。除了结合上面我说的之外,你还需要将 SubAgent 与 Persona 结合起来——因为 Persona 是一个包含了 tool、skills、name、description 的完整体,所以 SubAgent 应该直接继承 Persona 的定义,而不是单独定义 SubAgent。SubAgent 本质上就是一个有特定角色和能力的 Persona!多么美妙的设计啊!
+6. 为了实现大一统,is_background_task = True 的时候,后台任务也挂到 CronJobManager 上去管理,只不过这个是立即触发的任务,不需要等到未来某个时间点才触发罢了。
+
+我希望设计尽可能简单,但是强大。
diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py
index 9401e551d4..85ca581bc6 100644
--- a/astrbot/core/cron/manager.py
+++ b/astrbot/core/cron/manager.py
@@ -7,6 +7,7 @@
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
+from apscheduler.triggers.date import DateTrigger
from astrbot import logger
from astrbot.core.agent.tool import ToolSet
@@ -91,13 +92,18 @@ async def add_active_job(
self,
*,
name: str,
- cron_expression: str,
+ cron_expression: str | None,
payload: dict,
description: str | None = None,
timezone: str | None = None,
enabled: bool = True,
persistent: bool = True,
+ run_once: bool = False,
+ run_at: datetime | None = None,
) -> CronJob:
+ # If run_once with run_at, store run_at in payload for later reference.
+ if run_once and run_at:
+ payload = {**payload, "run_at": run_at.isoformat()}
job = await self.db.create_cron_job(
name=name,
job_type="active_agent",
@@ -107,6 +113,7 @@ async def add_active_job(
description=description,
enabled=enabled,
persistent=persistent,
+ run_once=run_once,
)
if enabled:
self._schedule_job(job)
@@ -148,7 +155,19 @@ def _schedule_job(self, job: CronJob):
job.timezone,
job.job_id,
)
- trigger = CronTrigger.from_crontab(job.cron_expression, timezone=tzinfo)
+ if job.run_once:
+ run_at_str = None
+ if isinstance(job.payload, dict):
+ run_at_str = job.payload.get("run_at")
+ run_at_str = run_at_str or job.cron_expression
+ if not run_at_str:
+ raise ValueError("run_once job missing run_at timestamp")
+ run_at = datetime.fromisoformat(run_at_str)
+ if run_at.tzinfo is None and tzinfo is not None:
+ run_at = run_at.replace(tzinfo=tzinfo)
+ trigger = DateTrigger(run_date=run_at, timezone=tzinfo)
+ else:
+ trigger = CronTrigger.from_crontab(job.cron_expression, timezone=tzinfo)
self.scheduler.add_job(
self._run_job,
id=job.job_id,
@@ -199,6 +218,9 @@ async def _run_job(self, job_id: str):
last_error=last_error,
next_run_time=next_run,
)
+ if job.run_once:
+ # one-shot: remove after execution regardless of success
+ await self.delete_job(job_id)
async def _run_basic_job(self, job: CronJob):
handler = self._basic_handlers.get(job.job_id)
@@ -221,9 +243,13 @@ async def _run_active_agent_job(self, job: CronJob, start_time: datetime):
"id": job.job_id,
"name": job.name,
"type": job.job_type,
+ "run_once": job.run_once,
"description": job.description,
"note": note,
"run_started_at": start_time.isoformat(),
+ "run_at": (
+ job.payload.get("run_at") if isinstance(job.payload, dict) else None
+ ),
},
"cron_payload": payload,
}
@@ -273,11 +299,13 @@ async def _woke_main_agent(
# judge user's role
umo = cron_event.unified_msg_origin
cfg = self.ctx.get_config(umo=umo)
+ cron_payload = extras.get("cron_payload", {}) if extras else {}
+ sender_id = cron_payload.get("sender_id")
admin_ids = cfg.get("admins_id", [])
if admin_ids:
- cron_event.role = (
- "admin" if cron_event.get_sender_id() in admin_ids else "member"
- )
+ cron_event.role = "admin" if sender_id in admin_ids else "member"
+ if cron_payload.get("origin", "tool") == "api":
+ cron_event.role = "admin"
config = MainAgentBuildConfig(
tool_call_timeout=3600,
diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py
index d602f22342..7b67b87554 100644
--- a/astrbot/core/db/__init__.py
+++ b/astrbot/core/db/__init__.py
@@ -528,6 +528,7 @@ async def create_cron_job(
description: str | None = None,
enabled: bool = True,
persistent: bool = True,
+ run_once: bool = False,
status: str | None = None,
job_id: str | None = None,
) -> CronJob:
@@ -546,6 +547,7 @@ async def update_cron_job(
description: str | None = None,
enabled: bool | None = None,
persistent: bool | None = None,
+ run_once: bool | None = None,
status: str | None = None,
next_run_time: datetime.datetime | None = None,
last_run_at: datetime.datetime | None = None,
diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py
index a63e5ea1eb..81649c0d7d 100644
--- a/astrbot/core/db/po.py
+++ b/astrbot/core/db/po.py
@@ -163,6 +163,7 @@ class CronJob(TimestampMixin, SQLModel, table=True):
payload: dict = Field(default_factory=dict, sa_type=JSON)
enabled: bool = Field(default=True)
persistent: bool = Field(default=True)
+ run_once: bool = Field(default=False)
status: str = Field(default="scheduled", max_length=32)
last_run_at: datetime | None = Field(default=None)
next_run_time: datetime | None = Field(default=None)
diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py
index b3aeb5c384..153e13e8b3 100644
--- a/astrbot/core/db/sqlite.py
+++ b/astrbot/core/db/sqlite.py
@@ -1594,6 +1594,7 @@ async def create_cron_job(
description: str | None = None,
enabled: bool = True,
persistent: bool = True,
+ run_once: bool = False,
status: str | None = None,
job_id: str | None = None,
) -> CronJob:
@@ -1609,6 +1610,7 @@ async def create_cron_job(
description=description,
enabled=enabled,
persistent=persistent,
+ run_once=run_once,
status=status or "scheduled",
)
if job_id:
@@ -1629,6 +1631,7 @@ async def update_cron_job(
description: str | None | object = CRON_FIELD_NOT_SET,
enabled: bool | None | object = CRON_FIELD_NOT_SET,
persistent: bool | None | object = CRON_FIELD_NOT_SET,
+ run_once: bool | None | object = CRON_FIELD_NOT_SET,
status: str | None | object = CRON_FIELD_NOT_SET,
next_run_time: datetime | None | object = CRON_FIELD_NOT_SET,
last_run_at: datetime | None | object = CRON_FIELD_NOT_SET,
@@ -1646,6 +1649,7 @@ async def update_cron_job(
"description": description,
"enabled": enabled,
"persistent": persistent,
+ "run_once": run_once,
"status": status,
"next_run_time": next_run_time,
"last_run_at": last_run_at,
diff --git a/astrbot/core/tools/cron_tools.py b/astrbot/core/tools/cron_tools.py
index a605e8e77e..f3e9f1ca43 100644
--- a/astrbot/core/tools/cron_tools.py
+++ b/astrbot/core/tools/cron_tools.py
@@ -1,3 +1,4 @@
+from datetime import datetime
from pydantic import Field
from pydantic.dataclasses import dataclass
@@ -10,8 +11,8 @@
class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
name: str = "create_future_task"
description: str = (
- "Create a future task for your future using a cron expression. "
- "Use this when you or the user want recurring follow-up (e.g., daily report to self)."
+ "Create a future task for your future. Supports recurring cron expressions or one-time run_at datetime. "
+ "Use this when you or the user want scheduled follow-up or proactive actions."
)
parameters: dict = Field(
default_factory=lambda: {
@@ -19,7 +20,11 @@ class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
"properties": {
"cron_expression": {
"type": "string",
- "description": "Cron expression defining when your future agent should wake (e.g., '0 8 * * *').",
+ "description": "Cron expression defining recurring schedule (e.g., '0 8 * * *').",
+ },
+ "run_at": {
+ "type": "string",
+ "description": "ISO datetime for one-time execution, e.g., 2026-02-02T08:00:00+08:00. Use with run_once=true.",
},
"note": {
"type": "string",
@@ -29,8 +34,12 @@ class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
"type": "string",
"description": "Optional label to recognize this future task.",
},
+ "run_once": {
+ "type": "boolean",
+ "description": "If true, the task will run only once and then be deleted. Use run_at to specify the time.",
+ },
},
- "required": ["cron_expression", "note"],
+ "required": ["note"],
}
)
@@ -42,28 +51,48 @@ async def call(
return "error: cron manager is not available."
cron_expression = kwargs.get("cron_expression")
+ run_at = kwargs.get("run_at")
+ run_once = bool(kwargs.get("run_once", False))
note = str(kwargs.get("note", "")).strip()
name = str(kwargs.get("name") or "").strip() or "active_agent_task"
- if not cron_expression or not note:
- return "error: cron_expression and note are required."
+ if not note:
+ return "error: note is required."
+ if run_once and not run_at:
+ return "error: run_at is required when run_once=true."
+ if (not run_once) and not cron_expression:
+ return "error: cron_expression is required when run_once=false."
+ if run_once and cron_expression:
+ cron_expression = None
+ run_at_dt = None
+ if run_at:
+ try:
+ run_at_dt = datetime.fromisoformat(str(run_at))
+ except Exception:
+ return "error: run_at must be ISO datetime, e.g., 2026-02-02T08:00:00+08:00"
payload = {
"session": context.context.event.unified_msg_origin,
+ "sender_id": context.context.event.get_sender_id(),
"note": note,
+ "origin": "tool",
}
job = await cron_mgr.add_active_job(
name=name,
- cron_expression=str(cron_expression),
+ cron_expression=str(cron_expression) if cron_expression else None,
payload=payload,
description=note,
+ run_once=run_once,
+ run_at=run_at_dt,
)
- next_run = job.next_run_time
- return (
- f"Scheduled future task {job.job_id} ({job.name}) with expression '{cron_expression}'. "
- f"You will be awakened at: {next_run}"
+ next_run = job.next_run_time or run_at_dt
+ suffix = (
+ f"one-time at {next_run}"
+ if run_once
+ else f"expression '{cron_expression}' (next {next_run})"
)
+ return f"Scheduled future task {job.job_id} ({job.name}) {suffix}."
@dataclass
@@ -125,7 +154,7 @@ async def call(
lines = []
for j in jobs:
lines.append(
- f"{j.job_id} | {j.name} | {j.job_type} | enabled={j.enabled} | next={j.next_run_time}"
+ f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={j.next_run_time}"
)
return "\n".join(lines)
diff --git a/astrbot/dashboard/routes/cron.py b/astrbot/dashboard/routes/cron.py
index 80c6926840..ae739273a1 100644
--- a/astrbot/dashboard/routes/cron.py
+++ b/astrbot/dashboard/routes/cron.py
@@ -31,6 +31,8 @@ def _serialize_job(self, job):
# expose note explicitly for UI (prefer payload.note then description)
payload = data.get("payload") or {}
data["note"] = payload.get("note") or data.get("description") or ""
+ data["run_at"] = payload.get("run_at")
+ data["run_once"] = data.get("run_once", False)
# status is internal; hide to avoid implying one-time completion for recurring jobs
data.pop("status", None)
return data
@@ -62,7 +64,6 @@ async def create_job(self):
if not isinstance(payload, dict):
return jsonify(Response().error("Invalid payload").__dict__)
- job_type = payload.get("job_type", "active_agent")
name = payload.get("name") or "active_agent_task"
cron_expression = payload.get("cron_expression")
note = payload.get("note") or payload.get("description") or name
@@ -71,28 +72,43 @@ async def create_job(self):
provider_id = payload.get("provider_id")
timezone = payload.get("timezone")
enabled = bool(payload.get("enabled", True))
+ run_once = bool(payload.get("run_once", False))
+ run_at = payload.get("run_at")
- if not cron_expression or not session:
+ if not session:
+ return jsonify(
+ Response().error("session is required").__dict__
+ )
+ if run_once and not run_at:
+ return jsonify(
+ Response().error("run_at is required when run_once=true").__dict__
+ )
+ if (not run_once) and not cron_expression:
return jsonify(
Response()
- .error("cron_expression and session are required")
+ .error("cron_expression is required when run_once=false")
.__dict__
)
+ if run_once and cron_expression:
+ cron_expression = None # ignore cron when run_once specified
+ run_at_dt = None
+ if run_at:
+ try:
+ run_at_dt = datetime.fromisoformat(str(run_at))
+ except Exception:
+ return jsonify(
+ Response().error("run_at must be ISO datetime").__dict__
+ )
job_payload = {
"session": session,
"note": note,
"persona_id": persona_id,
"provider_id": provider_id,
+ "run_at": run_at,
+ "origin": "api",
}
- if job_type != "active_agent":
- return jsonify(
- Response()
- .error("Only active_agent jobs are supported now.")
- .__dict__
- )
-
job = await cron_mgr.add_active_job(
name=name,
cron_expression=cron_expression,
@@ -100,6 +116,8 @@ async def create_job(self):
description=note,
timezone=timezone,
enabled=enabled,
+ run_once=run_once,
+ run_at=run_at_dt,
)
return jsonify(Response().ok(data=self._serialize_job(job)).__dict__)
@@ -125,9 +143,16 @@ async def update_job(self, job_id: str):
"description": payload.get("description"),
"enabled": payload.get("enabled"),
"timezone": payload.get("timezone"),
+ "run_once": payload.get("run_once"),
+ "payload": payload.get("payload"),
}
# remove None values to avoid unwanted resets
updates = {k: v for k, v in updates.items() if v is not None}
+ if "run_at" in payload:
+ updates.setdefault("payload", {})
+ if updates["payload"] is None:
+ updates["payload"] = {}
+ updates["payload"]["run_at"] = payload.get("run_at")
job = await cron_mgr.update_job(job_id, **updates)
if not job:
diff --git a/dashboard/src/views/CronJobPage.vue b/dashboard/src/views/CronJobPage.vue
index 23d2eb561f..4cf69ee1a3 100644
--- a/dashboard/src/views/CronJobPage.vue
+++ b/dashboard/src/views/CronJobPage.vue
@@ -16,6 +16,7 @@
+ 新建任务
刷新
@@ -35,11 +36,16 @@
{{ item.description }}
- {{ item.job_type }}
+
+ {{ item.run_once ? '一次性' : (item.job_type || 'active_agent') }}
+
- {{ item.cron_expression || '—' }}
- {{ item.timezone || 'local' }}
+ {{ formatTime(item.run_at) }}
+
+
{{ item.cron_expression || '—' }}
+
{{ item.timezone || 'local' }}
+
{{ formatTime(item.next_run_time) }}
{{ formatTime(item.last_run_at) }}
@@ -58,6 +64,50 @@
{{ snackbar.message }}
+
+
+
+ 新建任务
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 创建
+
+
+
@@ -68,6 +118,18 @@ import axios from 'axios'
const loading = ref(false)
const jobs = ref([])
const proactivePlatforms = ref<{ id: string; name: string; display_name?: string }[]>([])
+const createDialog = ref(false)
+const creating = ref(false)
+const newJob = ref({
+ run_once: false,
+ name: '',
+ note: '',
+ cron_expression: '',
+ run_at: '',
+ session: '',
+ timezone: '',
+ enabled: true
+})
const snackbar = ref({ show: false, message: '', color: 'success' })
@@ -154,6 +216,60 @@ async function deleteJob(job: any) {
}
}
+function openCreate() {
+ resetNewJob()
+ createDialog.value = true
+}
+
+function resetNewJob() {
+ newJob.value = {
+ run_once: false,
+ name: '',
+ note: '',
+ cron_expression: '',
+ run_at: '',
+ session: '',
+ timezone: '',
+ enabled: true
+ }
+}
+
+async function createJob() {
+ if (!newJob.value.session) {
+ toast('请填写 session', 'warning')
+ return
+ }
+ if (!newJob.value.note) {
+ toast('请填写说明', 'warning')
+ return
+ }
+ if (!newJob.value.run_once && !newJob.value.cron_expression) {
+ toast('请填写 Cron 表达式', 'warning')
+ return
+ }
+ if (newJob.value.run_once && !newJob.value.run_at) {
+ toast('请选择执行时间', 'warning')
+ return
+ }
+ creating.value = true
+ try {
+ const payload: any = { ...newJob.value }
+ const res = await axios.post('/api/cron/jobs', payload)
+ if (res.data.status === 'ok') {
+ toast('创建成功')
+ createDialog.value = false
+ resetNewJob()
+ await loadJobs()
+ } else {
+ toast(res.data.message || '创建失败', 'error')
+ }
+ } catch (e: any) {
+ toast(e?.response?.data?.message || '创建失败', 'error')
+ } finally {
+ creating.value = false
+ }
+}
+
onMounted(() => {
loadJobs()
loadPlatforms()
From 382aaaf053dcc5f04316fb5471c15ad0d82e653b Mon Sep 17 00:00:00 2001
From: Soulter <905617992@qq.com>
Date: Sun, 1 Feb 2026 22:04:44 +0800
Subject: [PATCH 23/24] feat: i18n
---
astrbot/core/computer/tools/fs.py | 2 +-
astrbot/core/star/context.py | 2 +-
astrbot/core/tools/cron_tools.py | 1 +
astrbot/dashboard/routes/cron.py | 4 +-
dashboard/src/i18n/loader.ts | 2 +
.../src/i18n/locales/en-US/features/cron.json | 64 +++++++
.../i18n/locales/en-US/features/subagent.json | 53 ++++++
.../src/i18n/locales/zh-CN/features/cron.json | 64 +++++++
.../i18n/locales/zh-CN/features/subagent.json | 53 ++++++
dashboard/src/i18n/translations.ts | 12 +-
dashboard/src/views/CronJobPage.vue | 140 +++++++++------
dashboard/src/views/SubAgentPage.vue | 160 +++++++++++-------
12 files changed, 437 insertions(+), 120 deletions(-)
create mode 100644 dashboard/src/i18n/locales/en-US/features/cron.json
create mode 100644 dashboard/src/i18n/locales/en-US/features/subagent.json
create mode 100644 dashboard/src/i18n/locales/zh-CN/features/cron.json
create mode 100644 dashboard/src/i18n/locales/zh-CN/features/subagent.json
diff --git a/astrbot/core/computer/tools/fs.py b/astrbot/core/computer/tools/fs.py
index a1c0f848f4..9acc371b2c 100644
--- a/astrbot/core/computer/tools/fs.py
+++ b/astrbot/core/computer/tools/fs.py
@@ -187,7 +187,7 @@ async def call(
os.remove(local_path)
except Exception as e:
logger.error(f"Error removing temp file {local_path}: {e}")
-
+
return f"File downloaded successfully to {local_path} and sent to user. The file has been removed from local storage."
return f"File downloaded successfully to {local_path}"
diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py
index ec7cbbe9ef..c7438baf22 100644
--- a/astrbot/core/star/context.py
+++ b/astrbot/core/star/context.py
@@ -24,7 +24,6 @@
from astrbot.core.provider.entities import LLMResponse, ProviderRequest, ProviderType
from astrbot.core.provider.func_tool_manager import FunctionTool, FunctionToolManager
from astrbot.core.provider.manager import ProviderManager
-from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
from astrbot.core.provider.provider import (
EmbeddingProvider,
Provider,
@@ -36,6 +35,7 @@
ADAPTER_NAME_2_TYPE,
PlatformAdapterType,
)
+from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
from ..exceptions import ProviderNotFoundError
from .filter.command import CommandFilter
diff --git a/astrbot/core/tools/cron_tools.py b/astrbot/core/tools/cron_tools.py
index f3e9f1ca43..ee22b943da 100644
--- a/astrbot/core/tools/cron_tools.py
+++ b/astrbot/core/tools/cron_tools.py
@@ -1,4 +1,5 @@
from datetime import datetime
+
from pydantic import Field
from pydantic.dataclasses import dataclass
diff --git a/astrbot/dashboard/routes/cron.py b/astrbot/dashboard/routes/cron.py
index ae739273a1..6bef938590 100644
--- a/astrbot/dashboard/routes/cron.py
+++ b/astrbot/dashboard/routes/cron.py
@@ -76,9 +76,7 @@ async def create_job(self):
run_at = payload.get("run_at")
if not session:
- return jsonify(
- Response().error("session is required").__dict__
- )
+ return jsonify(Response().error("session is required").__dict__)
if run_once and not run_at:
return jsonify(
Response().error("run_at is required when run_once=true").__dict__
diff --git a/dashboard/src/i18n/loader.ts b/dashboard/src/i18n/loader.ts
index 5d39d0b645..4ea85a2130 100644
--- a/dashboard/src/i18n/loader.ts
+++ b/dashboard/src/i18n/loader.ts
@@ -52,6 +52,8 @@ export class I18nLoader {
{ name: 'features/auth', path: 'features/auth.json' },
{ name: 'features/chart', path: 'features/chart.json' },
{ name: 'features/dashboard', path: 'features/dashboard.json' },
+ { name: 'features/cron', path: 'features/cron.json' },
+ { name: 'features/subagent', path: 'features/subagent.json' },
{ name: 'features/alkaid/index', path: 'features/alkaid/index.json' },
{ name: 'features/alkaid/knowledge-base', path: 'features/alkaid/knowledge-base.json' },
{ name: 'features/alkaid/memory', path: 'features/alkaid/memory.json' },
diff --git a/dashboard/src/i18n/locales/en-US/features/cron.json b/dashboard/src/i18n/locales/en-US/features/cron.json
new file mode 100644
index 0000000000..60ec6b0551
--- /dev/null
+++ b/dashboard/src/i18n/locales/en-US/features/cron.json
@@ -0,0 +1,64 @@
+{
+ "page": {
+ "title": "Future Task Management",
+ "beta": "Beta",
+ "subtitle": "See scheduled tasks for AstrBot. AstrBot will wake up, run them, and deliver the results.",
+ "proactive": {
+ "supported": "Proactive delivery is available on: {platforms}",
+ "unsupported": "No proactive messaging platforms enabled. Turn them on in Platform settings."
+ }
+ },
+ "actions": {
+ "create": "New Task",
+ "refresh": "Refresh",
+ "delete": "Delete",
+ "cancel": "Cancel",
+ "submit": "Create"
+ },
+ "table": {
+ "title": "Registered Tasks",
+ "empty": "No tasks yet.",
+ "headers": {
+ "name": "Name",
+ "type": "Type",
+ "cron": "Cron",
+ "nextRun": "Next Run",
+ "lastRun": "Last Run",
+ "note": "Note",
+ "actions": "Actions"
+ },
+ "type": {
+ "once": "One-off",
+ "recurring": "Recurring",
+ "activeAgent": "Active Agent",
+ "workflow": "Workflow",
+ "unknown": "{type}"
+ },
+ "timezoneLocal": "local",
+ "notAvailable": "—"
+ },
+ "form": {
+ "title": "New Task",
+ "runOnce": "One-off task",
+ "name": "Task name",
+ "note": "Task description",
+ "cron": "Cron expression",
+ "cronPlaceholder": "0 9 * * *",
+ "runAt": "Run at",
+ "session": "Target session (platform_id:message_type:session_id)",
+ "timezone": "Timezone (optional, e.g. Asia/Shanghai)",
+ "enabled": "Enabled"
+ },
+ "messages": {
+ "loadFailed": "Failed to load tasks",
+ "updateFailed": "Failed to update",
+ "deleteSuccess": "Deleted",
+ "deleteFailed": "Failed to delete",
+ "sessionRequired": "Session is required",
+ "noteRequired": "Description is required",
+ "cronRequired": "Cron expression is required",
+ "runAtRequired": "Please select run time",
+ "createSuccess": "Created successfully",
+ "createFailed": "Failed to create"
+ }
+}
diff --git a/dashboard/src/i18n/locales/en-US/features/subagent.json b/dashboard/src/i18n/locales/en-US/features/subagent.json
new file mode 100644
index 0000000000..8c8ed34e53
--- /dev/null
+++ b/dashboard/src/i18n/locales/en-US/features/subagent.json
@@ -0,0 +1,53 @@
+{
+ "page": {
+ "title": "SubAgent Orchestration",
+ "beta": "Beta",
+ "subtitle": "The main LLM only chats and delegates; tools live on individual SubAgents."
+ },
+ "actions": {
+ "refresh": "Refresh",
+ "save": "Save",
+ "add": "Add SubAgent",
+ "delete": "Delete"
+ },
+ "switches": {
+ "enable": "Enable SubAgent orchestration",
+ "dedupe": "Deduplicate main LLM tools (hide tools duplicated by SubAgents)"
+ },
+ "description": {
+ "disabled": "When off: SubAgent is disabled; the main LLM mounts tools via persona rules (all by default) and calls them directly.",
+ "enabled": "When on: the main LLM keeps its own tools and mounts transfer_to_* delegate tools. With deduplication, tools overlapping with SubAgents are removed from the main tool set."
+ },
+ "section": {
+ "title": "SubAgents"
+ },
+ "cards": {
+ "statusEnabled": "Enabled",
+ "statusDisabled": "Disabled",
+ "unnamed": "Untitled SubAgent",
+ "transferPrefix": "transfer_to_{name}",
+ "switchLabel": "Enable",
+ "previewTitle": "Preview: handoff tool shown to the main LLM",
+ "personaChip": "Persona: {id}"
+ },
+ "form": {
+ "nameLabel": "Agent name (used for transfer_to_{name})",
+ "nameHint": "Use lowercase letters + underscores; must be globally unique.",
+ "providerLabel": "Chat Provider (optional)",
+ "providerHint": "Leave empty to follow the global default provider.",
+ "personaLabel": "Choose Persona",
+ "personaHint": "The SubAgent inherits the selected Persona's system settings and tools.",
+ "descriptionLabel": "Description for the main LLM (used to decide handoff)",
+ "descriptionHint": "Shown to the main LLM as the transfer_to_* tool description—keep it short and clear."
+ },
+ "messages": {
+ "loadConfigFailed": "Failed to load config",
+ "loadPersonaFailed": "Failed to load persona list",
+ "nameMissing": "A SubAgent is missing a name",
+ "nameInvalid": "Invalid SubAgent name: only lowercase letters/numbers/underscores, starting with a letter",
+ "nameDuplicate": "Duplicate SubAgent name: {name}",
+ "personaMissing": "SubAgent {name} has no persona selected",
+ "saveSuccess": "Saved successfully",
+ "saveFailed": "Failed to save"
+ }
+}
diff --git a/dashboard/src/i18n/locales/zh-CN/features/cron.json b/dashboard/src/i18n/locales/zh-CN/features/cron.json
new file mode 100644
index 0000000000..38e2f440e9
--- /dev/null
+++ b/dashboard/src/i18n/locales/zh-CN/features/cron.json
@@ -0,0 +1,64 @@
+{
+ "page": {
+ "title": "未来任务管理",
+ "beta": "Beta",
+ "subtitle": "查看给 AstrBot 布置的未来任务。AstrBot 将会被自动唤醒、执行任务,然后将结果告知任务布置方。",
+ "proactive": {
+ "supported": "主动发送结果仅支持以下平台:{platforms}",
+ "unsupported": "暂无支持主动消息的平台,请在平台设置中开启。"
+ }
+ },
+ "actions": {
+ "create": "新建任务",
+ "refresh": "刷新",
+ "delete": "删除",
+ "cancel": "取消",
+ "submit": "创建"
+ },
+ "table": {
+ "title": "已注册任务",
+ "empty": "暂无任务。",
+ "headers": {
+ "name": "名称",
+ "type": "类型",
+ "cron": "Cron",
+ "nextRun": "下一次执行",
+ "lastRun": "最近执行",
+ "note": "说明",
+ "actions": "操作"
+ },
+ "type": {
+ "once": "一次性",
+ "recurring": "循环",
+ "activeAgent": "Active Agent",
+ "workflow": "Workflow",
+ "unknown": "{type}"
+ },
+ "timezoneLocal": "本地时区",
+ "notAvailable": "—"
+ },
+ "form": {
+ "title": "新建任务",
+ "runOnce": "一次性任务",
+ "name": "任务名称",
+ "note": "任务说明",
+ "cron": "Cron 表达式",
+ "cronPlaceholder": "0 9 * * *",
+ "runAt": "执行时间",
+ "session": "目标 session (platform_id:message_type:session_id)",
+ "timezone": "时区(可选,如 Asia/Shanghai)",
+ "enabled": "启用"
+ },
+ "messages": {
+ "loadFailed": "获取任务失败",
+ "updateFailed": "更新失败",
+ "deleteSuccess": "已删除",
+ "deleteFailed": "删除失败",
+ "sessionRequired": "请填写 session",
+ "noteRequired": "请填写说明",
+ "cronRequired": "请填写 Cron 表达式",
+ "runAtRequired": "请选择执行时间",
+ "createSuccess": "创建成功",
+ "createFailed": "创建失败"
+ }
+}
diff --git a/dashboard/src/i18n/locales/zh-CN/features/subagent.json b/dashboard/src/i18n/locales/zh-CN/features/subagent.json
new file mode 100644
index 0000000000..16533ace45
--- /dev/null
+++ b/dashboard/src/i18n/locales/zh-CN/features/subagent.json
@@ -0,0 +1,53 @@
+{
+ "page": {
+ "title": "SubAgent 编排",
+ "beta": "Beta",
+ "subtitle": "主 LLM 只负责聊天与分派(handoff),工具挂载在各个 SubAgent 上。"
+ },
+ "actions": {
+ "refresh": "刷新",
+ "save": "保存",
+ "add": "新增 SubAgent",
+ "delete": "删除"
+ },
+ "switches": {
+ "enable": "启用 SubAgent 编排",
+ "dedupe": "主 LLM 去重重复工具(与 SubAgent 重叠的工具将被隐藏)"
+ },
+ "description": {
+ "disabled": "不启动:SubAgent 关闭;主 LLM 按 persona 规则挂载工具(默认全部),并直接调用。",
+ "enabled": "启动:主 LLM 会保留自身工具并挂载 transfer_to_* 委派工具。若开启“去重重复工具”,与 SubAgent 指定的工具重叠部分会从主 LLM 工具集中移除。"
+ },
+ "section": {
+ "title": "SubAgents"
+ },
+ "cards": {
+ "statusEnabled": "启用",
+ "statusDisabled": "停用",
+ "unnamed": "未命名 SubAgent",
+ "transferPrefix": "transfer_to_{name}",
+ "switchLabel": "启用",
+ "previewTitle": "预览:主 LLM 将看到的 handoff 工具",
+ "personaChip": "Persona: {id}"
+ },
+ "form": {
+ "nameLabel": "Agent 名称(用于 transfer_to_{name})",
+ "nameHint": "建议使用英文小写+下划线,且全局唯一",
+ "providerLabel": "Chat Provider(可选)",
+ "providerHint": "留空表示跟随全局默认 provider。",
+ "personaLabel": "选择 Persona",
+ "personaHint": "SubAgent 将直接继承所选 Persona 的系统设定与工具。",
+ "descriptionLabel": "对主 LLM 的描述(用于决定是否 handoff)",
+ "descriptionHint": "这段会作为 transfer_to_* 工具的描述给主 LLM 看,建议简短明确。"
+ },
+ "messages": {
+ "loadConfigFailed": "获取配置失败",
+ "loadPersonaFailed": "获取 Persona 列表失败",
+ "nameMissing": "存在未填写名称的 SubAgent",
+ "nameInvalid": "SubAgent 名称不合法:仅允许英文小写字母/数字/下划线,且需以字母开头",
+ "nameDuplicate": "SubAgent 名称重复:{name}",
+ "personaMissing": "SubAgent {name} 未选择 Persona",
+ "saveSuccess": "保存成功",
+ "saveFailed": "保存失败"
+ }
+}
diff --git a/dashboard/src/i18n/translations.ts b/dashboard/src/i18n/translations.ts
index dd67ca54a6..e2c64dcb9a 100644
--- a/dashboard/src/i18n/translations.ts
+++ b/dashboard/src/i18n/translations.ts
@@ -25,6 +25,7 @@ import zhCNSettings from './locales/zh-CN/features/settings.json';
import zhCNAuth from './locales/zh-CN/features/auth.json';
import zhCNChart from './locales/zh-CN/features/chart.json';
import zhCNDashboard from './locales/zh-CN/features/dashboard.json';
+import zhCNCron from './locales/zh-CN/features/cron.json';
import zhCNAlkaidIndex from './locales/zh-CN/features/alkaid/index.json';
import zhCNAlkaidKnowledgeBase from './locales/zh-CN/features/alkaid/knowledge-base.json';
import zhCNAlkaidMemory from './locales/zh-CN/features/alkaid/memory.json';
@@ -34,6 +35,7 @@ import zhCNKnowledgeBaseDocument from './locales/zh-CN/features/knowledge-base/d
import zhCNPersona from './locales/zh-CN/features/persona.json';
import zhCNMigration from './locales/zh-CN/features/migration.json';
import zhCNCommand from './locales/zh-CN/features/command.json';
+import zhCNSubagent from './locales/zh-CN/features/subagent.json';
import zhCNErrors from './locales/zh-CN/messages/errors.json';
import zhCNSuccess from './locales/zh-CN/messages/success.json';
@@ -63,6 +65,7 @@ import enUSSettings from './locales/en-US/features/settings.json';
import enUSAuth from './locales/en-US/features/auth.json';
import enUSChart from './locales/en-US/features/chart.json';
import enUSDashboard from './locales/en-US/features/dashboard.json';
+import enUSCron from './locales/en-US/features/cron.json';
import enUSAlkaidIndex from './locales/en-US/features/alkaid/index.json';
import enUSAlkaidKnowledgeBase from './locales/en-US/features/alkaid/knowledge-base.json';
import enUSAlkaidMemory from './locales/en-US/features/alkaid/memory.json';
@@ -72,6 +75,7 @@ import enUSKnowledgeBaseDocument from './locales/en-US/features/knowledge-base/d
import enUSPersona from './locales/en-US/features/persona.json';
import enUSMigration from './locales/en-US/features/migration.json';
import enUSCommand from './locales/en-US/features/command.json';
+import enUSSubagent from './locales/en-US/features/subagent.json';
import enUSErrors from './locales/en-US/messages/errors.json';
import enUSSuccess from './locales/en-US/messages/success.json';
@@ -105,6 +109,7 @@ export const translations = {
auth: zhCNAuth,
chart: zhCNChart,
dashboard: zhCNDashboard,
+ cron: zhCNCron,
alkaid: {
index: zhCNAlkaidIndex,
'knowledge-base': zhCNAlkaidKnowledgeBase,
@@ -117,7 +122,8 @@ export const translations = {
},
persona: zhCNPersona,
migration: zhCNMigration,
- command: zhCNCommand
+ command: zhCNCommand,
+ subagent: zhCNSubagent
},
messages: {
errors: zhCNErrors,
@@ -151,6 +157,7 @@ export const translations = {
auth: enUSAuth,
chart: enUSChart,
dashboard: enUSDashboard,
+ cron: enUSCron,
alkaid: {
index: enUSAlkaidIndex,
'knowledge-base': enUSAlkaidKnowledgeBase,
@@ -163,7 +170,8 @@ export const translations = {
},
persona: enUSPersona,
migration: enUSMigration,
- command: enUSCommand
+ command: enUSCommand,
+ subagent: enUSSubagent
},
messages: {
errors: enUSErrors,
diff --git a/dashboard/src/views/CronJobPage.vue b/dashboard/src/views/CronJobPage.vue
index 4cf69ee1a3..1e8cfb8e2f 100644
--- a/dashboard/src/views/CronJobPage.vue
+++ b/dashboard/src/views/CronJobPage.vue
@@ -3,58 +3,69 @@
-
未来任务管理
- Beta
+ {{ tm('page.title') }}
+ {{ tm('page.beta') }}
- 查看给 AstrBot 布置的未来任务。AstrBot 将会被自动唤醒、执行任务,然后将结果告知任务布置方。
- 主动发送结果仅支持以下平台:
+ {{ tm('page.subtitle') }}
- {{ proactivePlatforms.map((p) => `${p.display_name || p.name}(${p.id})`).join('、') }}
+ {{ tm('page.proactive.supported', { platforms: proactivePlatformText }) }}
- 暂无支持主动消息的平台,请在平台设置中开启。
+ {{ tm('page.proactive.unsupported') }}
- 新建任务
- 刷新
+ {{ tm('actions.create') }}
+ {{ tm('actions.refresh') }}
-
已注册任务
+
{{ tm('table.title') }}
- 暂无任务。
+ {{ tm('table.empty') }}
-
+
{{ item.name }}
{{ item.description }}
- {{ item.run_once ? '一次性' : (item.job_type || 'active_agent') }}
+ {{ jobTypeLabel(item) }}
{{ formatTime(item.run_at) }}
-
{{ item.cron_expression || '—' }}
-
{{ item.timezone || 'local' }}
+
{{ item.cron_expression || tm('table.notAvailable') }}
+
{{ item.timezone || tm('table.timezoneLocal') }}
{{ formatTime(item.next_run_time) }}
{{ formatTime(item.last_run_at) }}
- {{ item.note || '—' }}
+ {{ item.note || tm('table.notAvailable') }}
-
- 删除
+
+ {{ tm('actions.delete') }}
@@ -67,44 +78,44 @@
- 新建任务
+ {{ tm('form.title') }}
-
-
-
+
+
+
-
+
- 取消
- 创建
+ {{ tm('actions.cancel') }}
+ {{ tm('actions.submit') }}
@@ -112,8 +123,11 @@