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 @@ + + + + + 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' }}
- + + @@ -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') }} - + - + @@ -67,44 +78,44 @@ - 新建任务 + {{ tm('form.title') }} - - - + + + - + - 取消 - 创建 + {{ tm('actions.cancel') }} + {{ tm('actions.submit') }} @@ -112,8 +123,11 @@