From 9515578496accc3cce2bbf17f5f0e646443d6d23 Mon Sep 17 00:00:00 2001 From: YUZHEthefool <2804776511@qq.com> Date: Mon, 7 Sep 2026 01:47:05 +0800 Subject: [PATCH 1/5] feat(btw): BTW conversation/work dual-loop prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BTW is an experimental dual-loop Agent mode, disabled by default. The conversation loop receives every message; a rule classifier (opt-in) or the /work command routes tool-intensive requests to a detached work loop that reuses the established Agent request path. - Runtime: astrbot/core/agent/btw/ (work loop, sessions, classifier, route resolution, per-profile status registry, locale strings) plus conversation_loop.py as the BTW-mode ProcessStage entry point. With BTW disabled ProcessStage holds the upstream AgentRequestSubStage directly — the Agent path is master-identical. - Agent integration: MainAgentBuildConfig gains btw_enabled, loop_mode, per-loop provider/computer-use overrides, and plugin/MCP/Skill route assignments; all loop-specific branches are gated on btw_enabled and workspace Skills/local tools keep upstream behavior when BTW is off. - Authorization: no IM elevation path. High-risk tool.* actions stay Dashboard-only from IM; privilege isolation stays on computer_use_runtime. The conversation loop hard-disables the six high-risk tool types while BTW is enabled. - Work tasks acknowledge, then run in a runtime-owned background task; results replay the result-decorate stage onward (reply content-safety check, TTS/T2I decoration, platform delivery). Work entry and status queries are real builtin commands (/work , /work status) with stable command_ids backed by a per-profile runtime registry; replies are locale-aware. - Dashboard: CapabilityLoopSelector / PluginLoopSelector render the per-profile route configuration under the btw.enabled condition. - Docs: BTW sections in both astrbot-config references; computer.md states that IM inherits no high-risk action and the work loop adds no elevation path. Validation: ruff clean; focused BTW/auth/pipeline/builtin-command suites green (372 tests); dashboard vitest 271/271. --- astrbot/api/__init__.py | 8 + .../.astrbot-plugin/i18n/en-US.json | 10 +- .../.astrbot-plugin/i18n/zh-CN.json | 10 +- .../builtin_commands/commands/__init__.py | 2 + .../builtin_commands/commands/work.py | 52 ++++ .../builtin_stars/builtin_commands/main.py | 22 ++ astrbot/core/agent/btw/__init__.py | 15 + astrbot/core/agent/btw/i18n.py | 51 ++++ astrbot/core/agent/btw/loop_routes.py | 58 ++++ astrbot/core/agent/btw/runtime_registry.py | 60 ++++ astrbot/core/agent/btw/task_classifier.py | 108 +++++++ astrbot/core/agent/btw/types.py | 54 ++++ astrbot/core/agent/btw/work_loop.py | 174 +++++++++++ astrbot/core/agent/btw/work_sessions.py | 107 +++++++ astrbot/core/agent/conversation_loop.py | 152 +++++++++ astrbot/core/astr_agent_tool_exec.py | 101 ++++++ astrbot/core/astr_main_agent.py | 194 +++++++++++- astrbot/core/auth/service.py | 90 +++--- astrbot/core/config/default.py | 102 +++++++ .../method/agent_sub_stages/internal.py | 125 +++++++- astrbot/core/pipeline/process_stage/stage.py | 43 ++- astrbot/core/pipeline/scheduler.py | 60 +++- .../mdi-subset/materialdesignicons-subset.css | 6 +- .../materialdesignicons-webfont-subset.woff | Bin 18724 -> 18840 bytes .../materialdesignicons-webfont-subset.woff2 | Bin 14960 -> 15048 bytes .../shared/CapabilityLoopSelector.vue | 188 ++++++++++++ .../components/shared/ConfigItemRenderer.vue | 22 ++ .../components/shared/PluginLoopSelector.vue | 135 ++++++++ .../en-US/features/config-metadata.json | 60 ++++ .../i18n/locales/en-US/features/config.json | 20 ++ .../zh-CN/features/config-metadata.json | 60 ++++ .../i18n/locales/zh-CN/features/config.json | 20 ++ .../tests/capabilityLoopSelector.vitest.ts | 91 ++++++ dashboard/tests/pluginLoopSelector.vitest.ts | 73 +++++ docs/en/dev/astrbot-config.md | 29 +- docs/en/use/computer.md | 4 +- docs/zh/dev/astrbot-config.md | 30 +- docs/zh/use/computer.md | 4 +- tests/unit/test_agent_internal_process.py | 186 ++++++++++- tests/unit/test_astr_agent_tool_exec.py | 114 ++++++- tests/unit/test_astr_main_agent.py | 289 +++++++++++++++++- tests/unit/test_authorization_service.py | 62 ++++ tests/unit/test_btw.py | 224 ++++++++++++++ tests/unit/test_builtin_command_extensions.py | 28 ++ tests/unit/test_config.py | 34 +++ tests/unit/test_conversation_loop.py | 125 ++++++++ tests/unit/test_process_stage.py | 6 +- 47 files changed, 3330 insertions(+), 78 deletions(-) create mode 100644 astrbot/builtin_stars/builtin_commands/commands/work.py create mode 100644 astrbot/core/agent/btw/__init__.py create mode 100644 astrbot/core/agent/btw/i18n.py create mode 100644 astrbot/core/agent/btw/loop_routes.py create mode 100644 astrbot/core/agent/btw/runtime_registry.py create mode 100644 astrbot/core/agent/btw/task_classifier.py create mode 100644 astrbot/core/agent/btw/types.py create mode 100644 astrbot/core/agent/btw/work_loop.py create mode 100644 astrbot/core/agent/btw/work_sessions.py create mode 100644 astrbot/core/agent/conversation_loop.py create mode 100644 dashboard/src/components/shared/CapabilityLoopSelector.vue create mode 100644 dashboard/src/components/shared/PluginLoopSelector.vue create mode 100644 dashboard/tests/capabilityLoopSelector.vitest.ts create mode 100644 dashboard/tests/pluginLoopSelector.vitest.ts create mode 100644 tests/unit/test_btw.py create mode 100644 tests/unit/test_conversation_loop.py diff --git a/astrbot/api/__init__.py b/astrbot/api/__init__.py index 016633ce03..94aecf4db4 100644 --- a/astrbot/api/__init__.py +++ b/astrbot/api/__init__.py @@ -13,6 +13,14 @@ from astrbot.core.utils.error_redaction import safe_error _EXPORTS = { + "btw_work_latest_status": ( + "astrbot.core.agent.btw.runtime_registry", + "latest_status", + ), + "btw_work_manager_for": ( + "astrbot.core.agent.btw.runtime_registry", + "manager_for", + ), "AuthContext": ("astrbot.core.auth", "AuthContext"), "Decision": ("astrbot.core.auth", "Decision"), "Resource": ("astrbot.core.auth", "Resource"), diff --git a/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/en-US.json b/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/en-US.json index 49399f73eb..9b14961e37 100644 --- a/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/en-US.json +++ b/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/en-US.json @@ -147,6 +147,14 @@ "provider.models.current": "Current model: {model}", "provider.models.empty_current": "(empty)", "provider.models.hint": "Use /model set to switch models. Model names can be resolved across configured providers.", - "provider.models.invalid_index": "Invalid model index." + "provider.models.invalid_index": "Invalid model index.", + "work.status.none": "No BTW work task has run in this session.", + "work.status.body": "{body}", + "work.status.pending": "Queued: {task}", + "work.status.running": "Running: {task}", + "work.status.completed": "Completed: {task}", + "work.status.failed": "Failed: {task}", + "work.status.cancelled": "Cancelled: {task}", + "work.run.usage": "Usage: /work " } } diff --git a/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/zh-CN.json b/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/zh-CN.json index e576634804..39c9c0e613 100644 --- a/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/zh-CN.json +++ b/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/zh-CN.json @@ -147,6 +147,14 @@ "provider.models.current": "当前模型:{model}", "provider.models.empty_current": "(空)", "provider.models.hint": "使用 /model set <名称或序号> 切换模型。模型名也可以解析到其他已配置 Provider。", - "provider.models.invalid_index": "模型序号无效。" + "provider.models.invalid_index": "模型序号无效。", + "work.status.none": "本会话还没有 BTW 工作任务。", + "work.status.body": "{body}", + "work.status.pending": "排队中:{task}", + "work.status.running": "执行中:{task}", + "work.status.completed": "已完成:{task}", + "work.status.failed": "已失败:{task}", + "work.status.cancelled": "已取消:{task}", + "work.run.usage": "用法:/work <任务描述>" } } diff --git a/astrbot/builtin_stars/builtin_commands/commands/__init__.py b/astrbot/builtin_stars/builtin_commands/commands/__init__.py index d665dbf916..d46e2deca5 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/__init__.py +++ b/astrbot/builtin_stars/builtin_commands/commands/__init__.py @@ -11,6 +11,7 @@ from .provider import ProviderCommands from .session import SessionCommands from .variable import VariableCommands +from .work import WorkCommands __all__ = [ "AdminCommands", @@ -24,4 +25,5 @@ "ProviderCommands", "SessionCommands", "VariableCommands", + "WorkCommands", ] diff --git a/astrbot/builtin_stars/builtin_commands/commands/work.py b/astrbot/builtin_stars/builtin_commands/commands/work.py new file mode 100644 index 0000000000..545e86cd5c --- /dev/null +++ b/astrbot/builtin_stars/builtin_commands/commands/work.py @@ -0,0 +1,52 @@ +"""BTW work-loop commands (/work status, /work ).""" + + +from typing import Annotated + +from astrbot.api import btw_work_latest_status +from astrbot.api.event import AstrMessageEvent +from astrbot.api.event.filter import GreedyStr + +from .reply import reply_i18n + + +class WorkCommands: + """BTW work-loop command surface.""" + + def __init__(self, context) -> None: + self.context = context + + async def status(self, event: AstrMessageEvent) -> None: + """Show the newest work-session status for this origin.""" + config_id = getattr(getattr(event, "resource", None), "config_id", "") or "" + latest = await btw_work_latest_status( + config_id, + event.unified_msg_origin, + ) + if latest is None: + await reply_i18n(self.context, event, "work.status.none") + return + request, status = latest + body = await self.context.i18n.t(event, f"work.status.{status}", task=request) + await reply_i18n(self.context, event, "work.status.body", body=body) + + async def run( + self, + event: AstrMessageEvent, + task: Annotated[str, GreedyStr], + ) -> None: + """Dispatch the task text through the BTW work loop. + + The command handler tags the in-flight event so the process stage's + Agent request runs with the work-loop policy; the message text is + rewritten to the task body so downstream assembly sees only the task. + """ + task = (task or "").strip() + if not task: + await reply_i18n(self.context, event, "work.run.usage") + return + event.message_str = task + event.set_extra("should_run_command", False) + event.set_extra("should_run_llm", True) + event.set_extra("btw_loop", "work") + # Do not stop the event: the pipeline continues into the Agent stage. diff --git a/astrbot/builtin_stars/builtin_commands/main.py b/astrbot/builtin_stars/builtin_commands/main.py index 87e566a10c..89b6c22548 100644 --- a/astrbot/builtin_stars/builtin_commands/main.py +++ b/astrbot/builtin_stars/builtin_commands/main.py @@ -16,6 +16,7 @@ ProviderCommands, SessionCommands, VariableCommands, + WorkCommands, ) @@ -34,6 +35,7 @@ def __init__(self, context: star.PluginContext) -> None: self.provider_c = ProviderCommands(self.context) self.session_c = SessionCommands(self.context) self.variable_c = VariableCommands(self.context) + self.work_c = WorkCommands(self.context) @filter.command("help") async def help( @@ -54,6 +56,26 @@ async def bot_status(self, event: AstrMessageEvent) -> None: """Show version and session, LLM, and TTS switches""" await self.bot_c.status(event) + @filter.command_group("work") + def work(self) -> None: + """Inspect BTW work-loop tasks for this session""" + + @filter.permission("session.read") + @work.command("status") + async def work_status(self, event: AstrMessageEvent) -> None: + """Show the newest BTW work task and its status""" + await self.work_c.status(event) + + @filter.permission("session.read") + @work.command("run") + async def work_run( + self, + event: AstrMessageEvent, + task: Annotated[str, GreedyStr] = "", + ) -> None: + """Run a task through the BTW work loop""" + await self.work_c.run(event, task) + @filter.permission("session.manage") @bot.command("enable") async def bot_enable(self, event: AstrMessageEvent) -> None: diff --git a/astrbot/core/agent/btw/__init__.py b/astrbot/core/agent/btw/__init__.py new file mode 100644 index 0000000000..e70f0b386c --- /dev/null +++ b/astrbot/core/agent/btw/__init__.py @@ -0,0 +1,15 @@ +"""BTW conversation and work-loop primitives.""" + +from .task_classifier import TaskClassifier +from .types import TaskType, WorkSession, WorkSessionStatus +from .work_loop import WorkLoop +from .work_sessions import WorkSessionManager + +__all__ = [ + "TaskClassifier", + "TaskType", + "WorkLoop", + "WorkSession", + "WorkSessionManager", + "WorkSessionStatus", +] diff --git a/astrbot/core/agent/btw/i18n.py b/astrbot/core/agent/btw/i18n.py new file mode 100644 index 0000000000..f3ce162d17 --- /dev/null +++ b/astrbot/core/agent/btw/i18n.py @@ -0,0 +1,51 @@ +"""Locale-aware user-facing strings for the BTW work loop. + +The work loop runs in core without a plugin context, so it resolves the +locale from the event extra/session the same way ``PluginContext._locale`` +does, then looks the string up in these bundles. Missing locales fall back +to ``zh-CN``. +""" + + +LOCALES: dict[str, dict[str, str]] = { + "zh-CN": { + "btw.work.started": "🔧 工作任务已开始处理。", + "btw.work.status.pending": "工作任务正在排队。", + "btw.work.status.running": "工作任务正在执行。", + "btw.work.status.completed": "工作任务已完成。", + "btw.work.status.failed": "工作任务执行失败。", + "btw.work.status.cancelled": "工作任务已取消。", + }, + "en-US": { + "btw.work.started": "🔧 Work task started.", + "btw.work.status.pending": "The work task is queued.", + "btw.work.status.running": "The work task is running.", + "btw.work.status.completed": "The work task is completed.", + "btw.work.status.failed": "The work task failed.", + "btw.work.status.cancelled": "The work task was cancelled.", + }, +} + +_FALLBACK_LOCALE = "zh-CN" + + +def resolve_event_locale(event) -> str: + """Return the locale for an event (extra first, then the stored session).""" + getter = getattr(event, "get_extra", None) + if callable(getter): + try: + extra = getter("locale") + except Exception: # noqa: BLE001 + extra = None + if extra: + return str(extra) + return _FALLBACK_LOCALE + + +def text(locale: str, key: str) -> str: + """Return one BTW string for a locale, falling back to zh-CN then key.""" + bundle = LOCALES.get(locale) or LOCALES[_FALLBACK_LOCALE] + value = bundle.get(key) + if value is None: + value = LOCALES[_FALLBACK_LOCALE].get(key, key) + return value diff --git a/astrbot/core/agent/btw/loop_routes.py b/astrbot/core/agent/btw/loop_routes.py new file mode 100644 index 0000000000..d5061f798e --- /dev/null +++ b/astrbot/core/agent/btw/loop_routes.py @@ -0,0 +1,58 @@ +"""Shared BTW loop-route resolution. + +Plugin, MCP, and Skill capability routes follow one matching rule. Keeping a +single narrow implementation here prevents duplicated drift between the main +agent assembly and the handoff tool executor. +""" + + +_LOOP_VALUES = {"conversation", "work"} +_ALLOWED_ROUTES = _LOOP_VALUES | {"both"} + + +def route_is_available_in_loop( + routes: object, + *, + route_key: str, + route_id: str, + loop_mode: str, + default_loop: str = "both", +) -> bool: + """Return whether a configured capability is available in one BTW loop. + + Plugin and MCP callers use a work-only default so newly installed execution + capabilities cannot silently enter the conversation loop. Skills keep the + both-loop default because they inject instructions rather than execution + privileges. An explicit route always wins. + + Args: + routes: Saved route assignments (list of ``{route_key, loop}`` dicts, + or a legacy ``{route_id: loop}`` dict). + route_key: Assignment key identifying the capability. + route_id: The capability's identifier. + loop_mode: The loop asking for access (``conversation`` or ``work``). + default_loop: The loop used when no assignment exists. + + Returns: + Whether the capability is available in ``loop_mode``. + """ + if loop_mode not in _LOOP_VALUES or not route_id: + return True + + if default_loop not in _ALLOWED_ROUTES: + default_loop = "work" + route = default_loop + if isinstance(routes, dict): + candidate = routes.get(route_id, default_loop) + route = candidate if isinstance(candidate, str) else default_loop + elif isinstance(routes, list): + for entry in routes: + if not isinstance(entry, dict) or entry.get(route_key) != route_id: + continue + candidate = entry.get("loop", default_loop) + route = candidate if isinstance(candidate, str) else default_loop + break + + if route not in _ALLOWED_ROUTES: + route = default_loop + return route in {"both", loop_mode} diff --git a/astrbot/core/agent/btw/runtime_registry.py b/astrbot/core/agent/btw/runtime_registry.py new file mode 100644 index 0000000000..60808fbdd8 --- /dev/null +++ b/astrbot/core/agent/btw/runtime_registry.py @@ -0,0 +1,60 @@ +"""Per-profile registry exposing BTW work-session state to commands. + +The built-in ``work`` command group queries the newest work session for an +origin without owning the pipeline. The pipeline's ``ConversationLoop`` +registers its work-session manager under the owning profile's ``config_id`` +at initialization; the command group resolves the event's config and reads +through this registry. +""" + + +import asyncio + +from astrbot.core.agent.btw.types import WorkSessionStatus +from astrbot.core.agent.btw.work_sessions import WorkSessionManager + +_lock = asyncio.Lock() +_managers: dict[str, WorkSessionManager] = {} + + +def register(config_id: str, manager: WorkSessionManager) -> None: + """Bind one profile's work-session manager for command queries. + + Args: + config_id: The configuration profile that owns the pipeline. + manager: The profile's work-session manager. + """ + _managers[config_id] = manager + + +def unregister(config_id: str) -> None: + """Drop one profile's registration (pipeline shutdown).""" + _managers.pop(config_id, None) + + +def manager_for(config_id: str) -> WorkSessionManager | None: + """Return the profile's registered work-session manager.""" + return _managers.get(config_id) + + +async def latest_status( + config_id: str, origin: str +) -> tuple[str, WorkSessionStatus] | None: + """Return the newest work session ``(request, status)`` for an origin. + + Args: + config_id: The configuration profile to query. + origin: The unified message origin. + + Returns: + The newest session's request text and status, or ``None`` when the + profile has no live work sessions for the origin. + """ + async with _lock: + manager = _managers.get(config_id) + if manager is None: + return None + session = await manager.get_for_origin(origin) + if session is None: + return None + return (session.request, session.status) diff --git a/astrbot/core/agent/btw/task_classifier.py b/astrbot/core/agent/btw/task_classifier.py new file mode 100644 index 0000000000..26aacf4b7a --- /dev/null +++ b/astrbot/core/agent/btw/task_classifier.py @@ -0,0 +1,108 @@ +"""Rule-based task classification for the BTW prototype.""" + +import re +from collections.abc import Mapping + +from astrbot.core.platform.astr_message_event import AstrMessageEvent + +from .types import TaskType + +DEFAULT_WORK_KEYWORDS = ( + "写代码", + "生成代码", + "修改代码", + "重构", + "创建文件", + "修改文件", + "读取文件", + "执行命令", + "运行命令", + "代码代理", + "编程代理", + "write code", + "generate code", + "refactor", + "create file", + "modify file", + "run command", + "claude code", + "claudecode", + "codex", + "opencode", + "coding agent", + "vibe coding", + "hapi", +) + +# CJK has no whitespace word boundaries, so only latin/digit keywords get +# token-boundary matching; CJK keywords still use substring matching. +_LATIN_KEYWORD_RE_CACHE: dict[str, re.Pattern[str]] = {} + + +def _keyword_matches(keyword: str, message: str) -> bool: + """Match one keyword against the lowercased message. + + Latin/ASCII keywords require a word boundary so that e.g. ``search`` does + not fire inside ``research``. CJK keywords (no whitespace boundaries) + fall back to substring matching. + """ + if re.fullmatch(r"[\W一-鿿]+", keyword, re.ASCII) is None: + # keyword contains at least one ASCII letter/digit: boundary match + pattern = _LATIN_KEYWORD_RE_CACHE.get(keyword) + if pattern is None: + pattern = re.compile( + r"(? None: + self.config = config + + async def classify(self, event: AstrMessageEvent) -> TaskType: + """Return the loop appropriate for an event. + + Args: + event: The incoming message event. + + Returns: + The selected task type. + """ + btw = self.config.get("btw", {}) + if not isinstance(btw, Mapping) or not btw.get("enabled", False): + return TaskType.CONVERSATION + + work_loop = btw.get("work_loop", {}) + if not isinstance(work_loop, Mapping) or not work_loop.get("enabled", False): + return TaskType.CONVERSATION + + message = (event.message_str or "").strip().lower() + if message.startswith("/work"): + return TaskType.WORK + + classifier = btw.get("classifier", {}) + if not isinstance(classifier, Mapping) or not classifier.get("enabled", False): + return TaskType.CONVERSATION + keywords = classifier.get("work_keywords", DEFAULT_WORK_KEYWORDS) + if not isinstance(keywords, list | tuple): + keywords = DEFAULT_WORK_KEYWORDS + if any( + isinstance(keyword, str) + and keyword.strip() + and _keyword_matches(keyword.strip(), message) + for keyword in keywords + ): + return TaskType.WORK + return TaskType.CONVERSATION diff --git a/astrbot/core/agent/btw/types.py b/astrbot/core/agent/btw/types.py new file mode 100644 index 0000000000..3e8be5abf3 --- /dev/null +++ b/astrbot/core/agent/btw/types.py @@ -0,0 +1,54 @@ +"""Types shared by the BTW conversation and work loops.""" + + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from enum import StrEnum +from uuid import uuid4 + + +class TaskType(StrEnum): + """The execution loop selected for a user request.""" + + CONVERSATION = "conversation" + WORK = "work" + + +class WorkSessionStatus(StrEnum): + """Lifecycle states for one work-loop request.""" + + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +@dataclass(slots=True) +class WorkSession: + """Runtime state shared by the conversation and work loops.""" + + origin: str + request: str + task_type: TaskType = TaskType.WORK + id: str = field(default_factory=lambda: uuid4().hex) + status: WorkSessionStatus = WorkSessionStatus.PENDING + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + updated_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + error: str | None = None + + def update_status( + self, + status: WorkSessionStatus, + *, + error: str | None = None, + ) -> None: + """Record a status transition. + + Args: + status: The new work-session status. + error: A safe diagnostic for failed work, when available. + """ + self.status = status + self.error = error + self.updated_at = datetime.now(UTC) diff --git a/astrbot/core/agent/btw/work_loop.py b/astrbot/core/agent/btw/work_loop.py new file mode 100644 index 0000000000..b81ba5e2fa --- /dev/null +++ b/astrbot/core/agent/btw/work_loop.py @@ -0,0 +1,174 @@ +"""The BTW work-loop prototype backed by the existing Agent tool loop.""" + +import asyncio +from collections.abc import AsyncGenerator, Awaitable, Callable +from typing import Protocol + +from astrbot.core.message.message_event_result import MessageEventResult +from astrbot.core.platform.astr_message_event import AstrMessageEvent +from astrbot.core.utils.error_redaction import safe_error +from astrbot.core.utils.task_utils import create_tracked_task + +from . import i18n as work_i18n +from .types import WorkSessionStatus +from .work_sessions import WorkSessionManager + + +class AgentRequestExecutor(Protocol): + """The existing Agent request path required by the work loop.""" + + def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: + """Yield pipeline progress markers for one event. + + Protocol stub; concrete implementations are the pipeline's Agent + request sub-stage. The body raises so the statement is effectful + (CodeQL py/ineffectual-statement); the unreachable ``yield`` keeps + the declared ``AsyncGenerator`` return type type-checkable. + """ + raise NotImplementedError + yield # noqa: B901 -- unreachable marker for the type checker + + +ResultDispatcher = Callable[[AstrMessageEvent], Awaitable[None]] +EventFinalizer = Callable[[AstrMessageEvent], Awaitable[None]] + + +class WorkLoop: + """Run classified work with the current Agent and tool infrastructure.""" + + def __init__( + self, + executor: AgentRequestExecutor, + sessions: WorkSessionManager, + *, + max_concurrent: int = 2, + ) -> None: + self.executor = executor + self.sessions = sessions + self._semaphore = asyncio.Semaphore(max(1, max_concurrent)) + self._background_tasks: set[asyncio.Task] | None = None + self._result_dispatcher: ResultDispatcher | None = None + self._event_finalizer: EventFinalizer | None = None + + def configure_detached_execution( + self, + *, + background_tasks: set[asyncio.Task], + result_dispatcher: ResultDispatcher, + event_finalizer: EventFinalizer, + ) -> None: + """Attach runtime-owned background execution services. + + Args: + background_tasks: Runtime task registry cancelled during shutdown. + result_dispatcher: Delivers a generated work result through the + configured result-decorate and response stages. + event_finalizer: Releases the event after detached work finishes. + """ + self._background_tasks = background_tasks + self._result_dispatcher = result_dispatcher + self._event_finalizer = event_finalizer + + async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: + """Execute one work-loop request inline. + + Args: + event: The classified message event. + + Yields: + Pipeline progress markers emitted by the existing Agent executor. + """ + session = await self.sessions.create( + event.unified_msg_origin, event.message_str + ) + self._prepare_event(event, session.id) + async for progress in self._execute(event, session.id): + yield progress + + async def submit(self, event: AstrMessageEvent) -> AsyncGenerator[None]: + """Acknowledge work, then run it without retaining the request pipeline. + + Falls back to inline execution when no runtime task registry is + attached, which keeps the primitive usable in isolated tests. + """ + if ( + self._background_tasks is None + or self._result_dispatcher is None + or self._event_finalizer is None + ): + async for progress in self.process(event): + yield progress + return + + session = await self.sessions.create( + event.unified_msg_origin, event.message_str + ) + self._prepare_event(event, session.id) + event.set_result( + MessageEventResult().message( + work_i18n.text( + work_i18n.resolve_event_locale(event), "btw.work.started" + ) + ) + ) + yield + + # The first yield returns only after the normal response stages deliver + # the acknowledgement. Marking it here prevents the scheduler from + # releasing event-owned temporary files before the worker needs them. + event.set_extra("btw_detached_work", True) + create_tracked_task( + self._background_tasks, + self._run_detached(event, session.id), + name=f"btw_work:{session.id}", + ) + + @staticmethod + def _prepare_event(event: AstrMessageEvent, session_id: str) -> None: + """Mark an event so Agent assembly uses the work-loop policy.""" + event.set_extra("btw_work_session_id", session_id) + event.set_extra("btw_loop", "work") + event.set_extra("btw_agent_lock_key", f"{event.unified_msg_origin}:work") + + async def _execute( + self, + event: AstrMessageEvent, + session_id: str, + ) -> AsyncGenerator[None]: + """Run one already-created work session and update its lifecycle.""" + try: + async with self._semaphore: + await self.sessions.update_status( + session_id, + WorkSessionStatus.RUNNING, + ) + async for progress in self.executor.process(event): + yield progress + except asyncio.CancelledError: + await self.sessions.update_status( + session_id, + WorkSessionStatus.CANCELLED, + ) + raise + except Exception as exc: + await self.sessions.update_status( + session_id, + WorkSessionStatus.FAILED, + error=safe_error("", exc), + ) + raise + else: + await self.sessions.update_status( + session_id, + WorkSessionStatus.COMPLETED, + ) + + async def _run_detached(self, event: AstrMessageEvent, session_id: str) -> None: + """Run work in the runtime task registry and deliver each result.""" + assert self._result_dispatcher is not None + assert self._event_finalizer is not None + try: + async for _ in self._execute(event, session_id): + await self._result_dispatcher(event) + finally: + await self._event_finalizer(event) diff --git a/astrbot/core/agent/btw/work_sessions.py b/astrbot/core/agent/btw/work_sessions.py new file mode 100644 index 0000000000..47ae159578 --- /dev/null +++ b/astrbot/core/agent/btw/work_sessions.py @@ -0,0 +1,107 @@ +"""In-memory runtime ownership for BTW work sessions.""" + + +import asyncio +from datetime import UTC, datetime, timedelta + +from .types import WorkSession, WorkSessionStatus + + +class WorkSessionManager: + """Own active and recent work-loop session state for one pipeline. + + ``get_for_origin`` returns the *latest* session per origin: a new task + replaces the previous session's status-query target, while older + sessions stay addressable by id until they expire. Work-loop + concurrency is bounded by the work loop's semaphore, not here. + """ + + def __init__(self, *, max_age_seconds: int = 3600) -> None: + self._by_origin: dict[str, WorkSession] = {} + self._by_id: dict[str, WorkSession] = {} + self._lock = asyncio.Lock() + self.set_max_age_seconds(max_age_seconds) + + def set_max_age_seconds(self, value: int) -> None: + """Set the retention period for terminal sessions. + + Args: + value: Number of seconds to keep a completed, failed, or cancelled + session before a later manager operation removes it. + """ + self._max_age_seconds = max(1, value) if type(value) is int else 3600 + + async def create(self, origin: str, request: str) -> WorkSession: + """Create and register a work session. + + Args: + origin: The unified message origin that owns the work. + request: The user request being processed. + + Returns: + The newly created work session. + """ + session = WorkSession(origin=origin, request=request) + async with self._lock: + self._cleanup_expired_locked() + self._by_origin[origin] = session + self._by_id[session.id] = session + return session + + async def get_for_origin(self, origin: str) -> WorkSession | None: + """Return the most recent work session for an origin.""" + async with self._lock: + self._cleanup_expired_locked() + return self._by_origin.get(origin) + + async def get_by_id(self, session_id: str) -> WorkSession | None: + """Return a recent work session by its identifier. + + Args: + session_id: The generated work-session identifier. + + Returns: + The matching session, or ``None`` after it has expired. + """ + async with self._lock: + self._cleanup_expired_locked() + return self._by_id.get(session_id) + + async def update_status( + self, + session_id: str, + status: WorkSessionStatus, + *, + error: str | None = None, + ) -> WorkSession | None: + """Transition one known work session. + + Args: + session_id: The work-session identifier. + status: The new lifecycle status. + error: A safe failure message, when applicable. + + Returns: + The updated session, or ``None`` when it has expired. + """ + async with self._lock: + self._cleanup_expired_locked() + session = self._by_id.get(session_id) + if session is not None: + session.update_status(status, error=error) + return session + + def _cleanup_expired_locked(self) -> None: + """Remove old terminal sessions while the manager lock is held.""" + cutoff = datetime.now(UTC) - timedelta(seconds=self._max_age_seconds) + expired_ids = [ + session_id + for session_id, session in self._by_id.items() + if session.status + not in {WorkSessionStatus.PENDING, WorkSessionStatus.RUNNING} + and session.updated_at < cutoff + ] + for session_id in expired_ids: + session = self._by_id.pop(session_id) + if self._by_origin.get(session.origin) is session: + self._by_origin.pop(session.origin, None) diff --git a/astrbot/core/agent/conversation_loop.py b/astrbot/core/agent/conversation_loop.py new file mode 100644 index 0000000000..574d303348 --- /dev/null +++ b/astrbot/core/agent/conversation_loop.py @@ -0,0 +1,152 @@ +"""The user-facing BTW conversation-loop entry point. + +The first BTW increment intentionally reuses the established Agent request +path. It therefore preserves the current local Tool Loop and third-party +Agent runner behaviour, while giving later classifier and work-loop work one +stable hand-off boundary. +""" + +import asyncio +from collections.abc import AsyncGenerator, Awaitable, Callable +from typing import TYPE_CHECKING + +from astrbot.core.agent.btw import ( + TaskClassifier, + TaskType, + WorkLoop, + WorkSessionManager, + WorkSessionStatus, + runtime_registry, +) +from astrbot.core.platform.astr_message_event import AstrMessageEvent + +if TYPE_CHECKING: + from astrbot.core.pipeline.context import PipelineContext + from astrbot.core.pipeline.process_stage.method.agent_request import ( + AgentRequestSubStage, + ) + + +class ConversationLoop: + """Process user-visible AI conversations through the current Agent path. + + It owns task classification and dispatches work requests to the work loop. + Both loops reuse the established Agent request executor. Plugin and MCP + execution capabilities default to the work loop unless an operator assigns + them to the conversation loop or both loops explicitly. When BTW is + disabled the loop is a transparent pass-through to the Agent request + executor, matching the upstream path exactly. + """ + + def __init__( + self, + agent_request: AgentRequestSubStage | None = None, + *, + classifier: TaskClassifier | None = None, + work_sessions: WorkSessionManager | None = None, + ) -> None: + if agent_request is None: + from ..pipeline.process_stage.method.agent_request import ( + AgentRequestSubStage, + ) + + agent_request = AgentRequestSubStage() + self.agent_request = agent_request + self.classifier = classifier + self.work_sessions = work_sessions or WorkSessionManager() + self.work_loop: WorkLoop | None = None + self._btw_enabled = False + + async def initialize(self, ctx: PipelineContext) -> None: + """Initialize the existing Agent request executor. + + Args: + ctx: The owning pipeline context. + """ + await self.agent_request.initialize(ctx) + if self.classifier is None: + self.classifier = TaskClassifier(ctx.astrbot_config) + btw = ctx.astrbot_config.get("btw", {}) + btw = btw if isinstance(btw, dict) else {} + self._btw_enabled = bool(btw.get("enabled", False)) + work_loop_config = btw.get("work_loop", {}) if isinstance(btw, dict) else {} + work_session_config = ( + btw.get("work_session", {}) if isinstance(btw, dict) else {} + ) + max_concurrent = ( + work_loop_config.get("max_concurrent", 2) + if isinstance(work_loop_config, dict) + else 2 + ) + max_age_seconds = ( + work_session_config.get("max_age_seconds", 3600) + if isinstance(work_session_config, dict) + else 3600 + ) + self.work_sessions.set_max_age_seconds(max_age_seconds) + self.work_loop = WorkLoop( + self.agent_request, + self.work_sessions, + max_concurrent=max_concurrent if isinstance(max_concurrent, int) else 2, + ) + + def configure_detached_work( + self, + *, + background_tasks: set[asyncio.Task], + result_dispatcher: Callable[[AstrMessageEvent], Awaitable[None]], + event_finalizer: Callable[[AstrMessageEvent], Awaitable[None]], + ) -> None: + """Attach runtime-owned execution callbacks to the work loop.""" + if self.work_loop is None: + raise RuntimeError("ConversationLoop must be initialized before use") + self.work_loop.configure_detached_execution( + background_tasks=background_tasks, + result_dispatcher=result_dispatcher, + event_finalizer=event_finalizer, + ) + + def expose_to_commands(self, config_id: str) -> None: + """Publish work-session state for the built-in ``work`` command group.""" + runtime_registry.register(config_id, self.work_sessions) + + async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: + """Run one conversation through the current Agent request path. + + When BTW is disabled the loop is a transparent pass-through: no + classification, no loop tagging — the event reaches the Agent request + executor exactly as it would on the upstream path. Work-session + status is queried through the ``/work status`` command, not by + inspecting message text. + + Args: + event: The message event to process. + + Yields: + Pipeline progress markers emitted by the Agent request executor. + """ + if not self._btw_enabled: + async for response in self.agent_request.process(event): + yield response + return + + if self.classifier is None: + raise RuntimeError("ConversationLoop must be initialized before use") + task_type = await self.classifier.classify(event) + if task_type is TaskType.WORK: + if self.work_loop is None: + raise RuntimeError("ConversationLoop must be initialized before use") + async for response in self.work_loop.submit(event): + yield response + return + + event.set_extra("btw_loop", "conversation") + async for response in self.agent_request.process(event): + yield response + + @staticmethod + def format_status(status: WorkSessionStatus, locale: str = "zh-CN") -> str: + """Render one work-session status as a user-facing string.""" + from astrbot.core.agent.btw import i18n as work_i18n + + return f"📊 {work_i18n.text(locale, f'btw.work.status.{status.value}')}" diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 27d69c62bb..66269a8455 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -11,6 +11,7 @@ import mcp from astrbot import logger +from astrbot.core.agent.btw.loop_routes import route_is_available_in_loop from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.llm_types import ProviderRequest from astrbot.core.agent.mcp_client import MCPTool @@ -346,6 +347,87 @@ def _get_runtime_computer_tools( } return {} + @staticmethod + def _route_is_available_in_loop( + routes: object, + *, + route_key: str, + route_id: str, + loop_mode: str, + default_loop: str = "both", + ) -> bool: + """Return whether a BTW route permits one nested handoff capability.""" + return route_is_available_in_loop( + routes, + route_key=route_key, + route_id=route_id, + loop_mode=loop_mode, + default_loop=default_loop, + ) + + @classmethod + def _filter_handoff_toolset_for_btw( + cls, + toolset: ToolSet, + *, + ctx, + cfg: dict, + event, + ) -> ToolSet: + """Apply BTW routes to tools exposed inside an existing handoff.""" + btw = cfg.get("btw", {}) + btw = btw if isinstance(btw, dict) else {} + if not btw.get("enabled", False): + # BTW disabled: the Agent path is master-identical, keep the + # handoff toolset as built. + return toolset + get_extra = getattr(event, "get_extra", None) + loop_mode = get_extra("btw_loop") if callable(get_extra) else None + if not isinstance(loop_mode, str) or loop_mode not in { + "conversation", + "work", + }: + return toolset + assert isinstance(loop_mode, str) + + plugins = getattr(getattr(ctx, "catalogs", None), "plugins", None) + filtered = ToolSet() + for tool in toolset.tools: + raw_tool = getattr(tool, "_wrapped", tool) + if loop_mode == "conversation" and type(raw_tool).__module__.startswith( + "astrbot.core.tools.computer_tools" + ): + continue + if isinstance(raw_tool, MCPTool) and not cls._route_is_available_in_loop( + btw.get("mcp_routes", []), + route_key="server_name", + route_id=raw_tool.mcp_server_name, + loop_mode=loop_mode, + default_loop="work", + ): + continue + module_path = getattr(raw_tool, "handler_module_path", None) + plugin = ( + plugins.get_by_module(module_path) + if plugins is not None and module_path + else None + ) + plugin_id = ( + getattr(plugin, "root_dir_name", None) + or getattr(plugin, "name", None) + or "" + ) + if plugin is not None and not cls._route_is_available_in_loop( + btw.get("plugin_routes", []), + route_key="plugin_id", + route_id=plugin_id, + loop_mode=loop_mode, + default_loop="work", + ): + continue + filtered.add_tool(tool) + return filtered + @classmethod def _build_handoff_toolset( cls, @@ -355,8 +437,15 @@ def _build_handoff_toolset( ctx = run_context.context.context event = run_context.context.event cfg = ctx.get_config(umo=event.unified_msg_origin) + btw = cfg.get("btw", {}) + btw = btw if isinstance(btw, dict) else {} + btw_enabled = bool(btw.get("enabled", False)) provider_settings = cfg.get("provider_settings", {}) runtime = str(provider_settings.get("computer_use_runtime", "local")) + get_extra = getattr(event, "get_extra", None) + loop_mode = get_extra("btw_loop") if callable(get_extra) else None + if btw_enabled and loop_mode == "conversation": + runtime = "none" # An explicitly empty handoff tool list needs no registry lookup. In # particular, this keeps the handoff execution path independent from @@ -387,6 +476,12 @@ def _build_handoff_toolset( toolset.add_tool(registered_tool) for runtime_tool in runtime_computer_tools.values(): toolset.add_tool(runtime_tool) + toolset = cls._filter_handoff_toolset_for_btw( + toolset, + ctx=ctx, + cfg=cfg, + event=event, + ) return None if toolset.empty() else toolset toolset = ToolSet() @@ -401,6 +496,12 @@ def _build_handoff_toolset( toolset.add_tool(runtime_tool) elif isinstance(tool_name_or_obj, FunctionTool): toolset.add_tool(tool_name_or_obj) + toolset = cls._filter_handoff_toolset_for_btw( + toolset, + ctx=ctx, + cfg=cfg, + event=event, + ) return None if toolset.empty() else toolset @classmethod diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 25405d092f..334e033fe7 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -11,6 +11,9 @@ from typing import Any, TypeGuard, cast from astrbot import logger +from astrbot.core.agent.btw.loop_routes import ( + route_is_available_in_loop as _route_is_available_in_loop, +) from astrbot.core.agent.chat_model import ChatModel from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.llm_types import ProviderRequest @@ -227,6 +230,15 @@ class MainAgentBuildConfig: fallback_provider_ids: list[str] = field(default_factory=list) request_max_retries: int = 5 subagent_orchestrator: dict = field(default_factory=dict) + btw_plugin_routes: object = field(default_factory=list) + btw_mcp_routes: object = field(default_factory=list) + btw_skill_routes: object = field(default_factory=list) + btw_enabled: bool = False + loop_mode: str = "conversation" + provider_id_override: str = "" + conversation_provider_id: str = "" + work_provider_id: str = "" + work_computer_use_runtime: str = "inherit" timezone: str | None = None max_quoted_fallback_images: int = 20 """Maximum number of images injected from quoted-message fallback extraction.""" @@ -355,10 +367,12 @@ def _set_llm_error_message(event: AstrMessageEvent, message: str) -> None: def _select_provider( - event: AstrMessageEvent, plugin_context: CoreExecutionContext + event: AstrMessageEvent, + plugin_context: CoreExecutionContext, + provider_id_override: str = "", ) -> ChatModel | None: """Select chat provider for the event.""" - sel_provider = event.get_extra("selected_provider") + sel_provider = provider_id_override or event.get_extra("selected_provider") if sel_provider and isinstance(sel_provider, str): provider = plugin_context.get_provider_by_id(sel_provider) if provider is None: @@ -562,6 +576,24 @@ def _filter_skills_for_current_config( return filtered +def _filter_skills_for_loop( + skills: list[SkillInfo], + routes: object, + loop_mode: str, +) -> list[SkillInfo]: + """Keep only Skills assigned to the current BTW loop.""" + return [ + skill + for skill in skills + if _route_is_available_in_loop( + routes, + route_key="skill_name", + route_id=skill.name, + loop_mode=loop_mode, + ) + ] + + def _get_context_runtime_attr(plugin_context: CoreExecutionContext, name: str): return getattr(plugin_context, "__dict__", {}).get(name) @@ -605,6 +637,10 @@ def _append_skills_prompt( persona: Personality | None, event: AstrMessageEvent, plugin_context: CoreExecutionContext, + *, + loop_mode: str = "conversation", + skill_routes: object = (), + btw_enabled: bool = False, ) -> None: runtime = cfg.get("computer_use_runtime", "local") skill_manager = plugin_context.skill_manager or SkillManager( @@ -615,11 +651,13 @@ def _append_skills_prompt( cfg, plugin_context.catalogs.plugins, ) + if btw_enabled: + skills = _filter_skills_for_loop(skills, skill_routes, loop_mode) workspace_skills = ( skill_manager.list_workspace_skills( _get_workspace_path_for_umo(event.unified_msg_origin) ) - if runtime == "local" + if runtime == "local" and (not btw_enabled or loop_mode == "work") else [] ) if persona and persona.get("skills") is not None: @@ -735,6 +773,10 @@ async def _ensure_persona_and_skills( cfg: dict, plugin_context: CoreExecutionContext, event: AstrMessageEvent, + *, + loop_mode: str = "conversation", + skill_routes: object = (), + btw_enabled: bool = False, ) -> None: """Ensure persona and skills are applied to the request's system prompt or user prompt.""" if not req.conversation: @@ -779,7 +821,16 @@ async def _ensure_persona_and_skills( memory_manager, ) - _append_skills_prompt(req, cfg, persona, event, plugin_context) + _append_skills_prompt( + req, + cfg, + persona, + event, + plugin_context, + loop_mode=loop_mode, + skill_routes=skill_routes, + btw_enabled=btw_enabled, + ) tmgr = plugin_context.get_llm_tool_manager() persona_toolset = _merge_persona_tools(req, persona, tmgr, memory_manager) @@ -1193,7 +1244,15 @@ async def _decorate_llm_request( quote_images_already_captioned = False if req.conversation: - await _ensure_persona_and_skills(req, cfg, plugin_context, event) + await _ensure_persona_and_skills( + req, + cfg, + plugin_context, + event, + loop_mode=config.loop_mode, + skill_routes=config.btw_skill_routes, + btw_enabled=config.btw_enabled, + ) if img_cap_prov_id and req.image_urls and not main_provider_supports_image: await _ensure_img_caption( @@ -1612,6 +1671,120 @@ async def _prepare_request_for_agent( return True +_CONVERSATION_FORBIDDEN_TOOL_TYPES = ( + AnnotateExecutionTool, + BrowserBatchExecTool, + BrowserExecTool, + CreateSkillCandidateTool, + CreateSkillPayloadTool, + CuaKeyboardTypeTool, + CuaMouseClickTool, + CuaScreenshotTool, + EvaluateSkillCandidateTool, + ExecuteShellTool, + FileDownloadTool, + FileEditTool, + FileReadTool, + FileUploadTool, + FileWriteTool, + GetExecutionHistoryTool, + GetSkillPayloadTool, + GrepTool, + ListSkillCandidatesTool, + ListSkillReleasesTool, + LocalPythonTool, + PromoteSkillCandidateTool, + PythonTool, + RollbackSkillReleaseTool, + RunBrowserSkillTool, + ShellSessionTool, + SyncSkillReleaseTool, +) + + +def _filter_privileged_tools_for_conversation( + req: ProviderRequest, + config: MainAgentBuildConfig, +) -> None: + """Ensure the conversation loop cannot call computer or filesystem tools. + + Gated on ``btw_enabled``: with BTW off the Agent path matches upstream + master exactly (no privileged-tool stripping). + """ + if ( + not config.btw_enabled + or config.loop_mode != "conversation" + or req.func_tool is None + ): + return + filtered = ToolSet() + for tool in req.func_tool.tools: + if isinstance(tool, _CONVERSATION_FORBIDDEN_TOOL_TYPES): + continue + filtered.add_tool(tool) + req.func_tool = filtered + + +def _filter_plugin_tools_for_loop( + req: ProviderRequest, + plugin_context: CoreExecutionContext, + config: MainAgentBuildConfig, +) -> None: + """Keep only plugin tools assigned to the current BTW loop. + + Built-in and MCP tools are not owned by a plugin and remain available for + their own policy checks. Omitted or malformed plugin assignments fail + closed to the work loop. When BTW is disabled the Agent path is + master-identical: every plugin tool stays mounted. + """ + if ( + not config.btw_enabled + or req.func_tool is None + or config.loop_mode not in {"conversation", "work"} + ): + return + filtered = ToolSet() + for tool in req.func_tool.tools: + plugin = plugin_context.catalogs.plugins.get_by_module(tool.handler_module_path) + if plugin is None: + filtered.add_tool(tool) + continue + plugin_id = plugin.root_dir_name or plugin.name or "" + if _route_is_available_in_loop( + config.btw_plugin_routes, + route_key="plugin_id", + route_id=plugin_id, + loop_mode=config.loop_mode, + default_loop="work", + ): + filtered.add_tool(tool) + req.func_tool = filtered + + +def _filter_mcp_tools_for_loop( + req: ProviderRequest, config: MainAgentBuildConfig +) -> None: + """Keep only MCP server tools assigned to the current BTW loop.""" + if ( + not config.btw_enabled + or req.func_tool is None + or config.loop_mode not in {"conversation", "work"} + ): + return + + filtered = ToolSet() + for tool in req.func_tool.tools: + if not isinstance(tool, MCPTool) or _route_is_available_in_loop( + config.btw_mcp_routes, + route_key="server_name", + route_id=tool.mcp_server_name, + loop_mode=config.loop_mode, + default_loop="work", + ): + filtered.add_tool(tool) + req.func_tool = filtered + + def _select_request_provider( provider: ChatModel, req: ProviderRequest, @@ -1961,7 +2134,11 @@ async def build_main_agent( If apply_reset is False, will not call reset on the agent runner. """ - provider = provider or _select_provider(event, plugin_context) + provider = provider or _select_provider( + event, + plugin_context, + config.provider_id_override, + ) if provider is None: logger.info("未找到任何对话模型(提供商),跳过 LLM 请求处理。") if not event.get_extra(LLM_ERROR_MESSAGE_EXTRA_KEY): @@ -2007,6 +2184,9 @@ async def build_main_agent( ): return None + _filter_plugin_tools_for_loop(req, plugin_context, config) + _filter_mcp_tools_for_loop(req, config) + if config.add_cron_tools: _proactive_cron_job_tools(req, plugin_context) @@ -2033,6 +2213,8 @@ async def build_main_agent( ) ) + _filter_privileged_tools_for_conversation(req, config) + provider, fallback_providers = _select_request_provider( provider, req, plugin_context, config ) diff --git a/astrbot/core/auth/service.py b/astrbot/core/auth/service.py index 9075494c37..b9e5b3794f 100644 --- a/astrbot/core/auth/service.py +++ b/astrbot/core/auth/service.py @@ -1,7 +1,5 @@ """Runtime-owned authorization, audit, and Dashboard step-up service.""" -from __future__ import annotations - import asyncio import hashlib import secrets @@ -1482,49 +1480,69 @@ async def _authorize( ) step_up_id: str | None = None if _requires_step_up(action, resource, context): - if context.source not in {"dashboard", "webchat"}: - return Decision( - False, - subject, - action, - resource, - role, - "high_risk_dashboard_only", - audit_id=audit_id, - matched_relations=tuple(item.relation.value for item in matched), - relation_sources=tuple(item.source for item in matched), - ) - if context.source == "webchat" and ( - subject.kind != "dashboard-account" - or action not in WEBCHAT_INSTANCE_TOOL_ACTIONS - or not context.authenticated - or context.origin_session_resource_id is None - ): - return Decision( - False, - subject, - action, - resource, - role, - "high_risk_dashboard_only", - audit_id=audit_id, - matched_relations=tuple(item.relation.value for item in matched), - relation_sources=tuple(item.source for item in matched), - ) - step_up_id = _webchat_step_up_cached(context, action) - if step_up_id is None: + if context.source == "dashboard": step_up_id = await self._consume_step_up( subject, action, resource, context ) - if step_up_id is None: + if step_up_id is None: + return Decision( + False, + subject, + action, + resource, + role, + "step_up_required", + requires_step_up=True, + audit_id=audit_id, + ) + elif context.source == "webchat": + if ( + subject.kind != "dashboard-account" + or action not in WEBCHAT_INSTANCE_TOOL_ACTIONS + or not context.authenticated + or context.origin_session_resource_id is None + ): + return Decision( + False, + subject, + action, + resource, + role, + "high_risk_dashboard_only", + audit_id=audit_id, + matched_relations=tuple( + item.relation.value for item in matched + ), + relation_sources=tuple(item.source for item in matched), + ) + step_up_id = _webchat_step_up_cached(context, action) + if step_up_id is None: + step_up_id = await self._consume_step_up( + subject, action, resource, context + ) + if step_up_id is None: + return Decision( + False, + subject, + action, + resource, + role, + "step_up_required", + requires_step_up=True, + audit_id=audit_id, + matched_relations=tuple( + item.relation.value for item in matched + ), + relation_sources=tuple(item.source for item in matched), + ) + else: return Decision( False, subject, action, resource, role, - "step_up_required", - requires_step_up=True, + "high_risk_dashboard_only", audit_id=audit_id, matched_relations=tuple(item.relation.value for item in matched), relation_sources=tuple(item.source for item in matched), diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 79582964e8..d5d7609c18 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -188,6 +188,30 @@ ), "agents": [], }, + "btw": { + # Experimental prototype: off by default. Enabling ``enabled`` also + # requires enabling the work loop; high-risk tool actions stay denied + # from IM per the upstream rules (no elevation path). + "enabled": False, + "classifier": { + "enabled": False, + }, + "conversation_loop": { + "provider_id": "", + }, + "work_loop": { + "enabled": False, + "provider_id": "", + "computer_use_runtime": "inherit", + "max_concurrent": 2, + }, + "work_session": { + "max_age_seconds": 3600, + }, + "plugin_routes": [], + "mcp_routes": [], + "skill_routes": [], + }, "provider_stt_settings": { "enable": False, "provider_id": "", @@ -4624,8 +4648,86 @@ }, }, }, + "btw": { + "description": "BTW 双循环", + "type": "object", + "items": { + "btw.enabled": { + "description": "启用 BTW 双循环", + "type": "bool", + "hint": "实验性原型,默认关闭。开启后由对话循环统一接收消息,并将显式工作请求转入工作循环;高风险工具动作仍然按上游规则拒绝,IM 不提权。", + }, + "btw.classifier.enabled": { + "description": "启用任务分类", + "type": "bool", + "hint": "可选的启发式规则,默认关闭。开启后按内置规则把疑似工作请求转入工作循环;/work 指令不依赖此开关。", + "condition": {"btw.enabled": True}, + }, + "btw.conversation_loop.provider_id": { + "description": "对话循环模型", + "type": "string", + "_special": "select_provider", + "hint": "留空时使用当前会话的默认对话模型。对话循环不会获得本地、沙盒或文件工具。", + "condition": {"btw.enabled": True}, + }, + "btw.work_loop.enabled": { + "description": "启用工作循环", + "type": "bool", + "hint": "关闭后,所有请求都由对话循环处理。", + "condition": {"btw.enabled": True}, + }, + "btw.work_loop.provider_id": { + "description": "工作循环模型", + "type": "string", + "_special": "select_provider", + "hint": "留空时使用当前会话的默认对话模型。配置后会优先于会话模型选择。", + "condition": {"btw.work_loop.enabled": True}, + }, + "btw.work_loop.computer_use_runtime": { + "description": "工作循环电脑权限", + "type": "string", + "options": ["inherit", "none", "local", "sandbox"], + "hint": "inherit 使用现有电脑使用配置;local 和 sandbox 只会暴露给工作循环。", + "condition": {"btw.work_loop.enabled": True}, + }, + "btw.work_loop.max_concurrent": { + "description": "工作循环最大并发数", + "type": "int", + "hint": "同一配置文件中可同时执行的工作任务数量。", + "condition": {"btw.work_loop.enabled": True}, + }, + "btw.work_session.max_age_seconds": { + "description": "工作会话保留时长", + "type": "int", + "hint": "已完成、失败或取消的工作任务保留多少秒以供状态查询。", + "condition": {"btw.enabled": True}, + }, + "btw.plugin_routes": { + "description": "插件工具循环分配", + "type": "list", + "hint": "插件 LLM 工具默认仅在工作循环可用;可为每个已启用插件显式改为对话循环或两者。", + "_special": "select_plugin_loop_routes", + "condition": {"btw.enabled": True}, + }, + "btw.mcp_routes": { + "description": "MCP 服务器循环分配", + "type": "list", + "hint": "MCP 工具默认仅在工作循环可用;可为每个已启用服务器显式改为对话循环或两者。", + "_special": "select_mcp_loop_routes", + "condition": {"btw.enabled": True}, + }, + "btw.skill_routes": { + "description": "Skills 循环分配", + "type": "list", + "hint": "Skill 默认注入两个循环;可为每个已启用 Skill 显式限制到单一循环。", + "_special": "select_skill_loop_routes", + "condition": {"btw.enabled": True}, + }, + }, + }, } + CONFIG_METADATA_3_SYSTEM = { "system_group": { "name": "系统配置", 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 c692f1f07c..71d9ee4493 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 @@ -25,6 +25,7 @@ ) from astrbot.core.astr_main_agent import ( LLM_ERROR_MESSAGE_EXTRA_KEY, + MainAgentBuildConfig, MainAgentBuildResult, build_main_agent, local_agent_runtime_from_profile, @@ -104,9 +105,56 @@ async def initialize(self, ctx: PipelineContext) -> None: False, ) self.show_reasoning = settings.get("display_reasoning_text", False) + + btw_config = conf.get("btw", {}) + btw_config = btw_config if isinstance(btw_config, dict) else {} + self.btw_enabled = bool(btw_config.get("enabled", False)) + conversation_loop_config = btw_config.get("conversation_loop", {}) + conversation_loop_config = ( + conversation_loop_config + if isinstance(conversation_loop_config, dict) + else {} + ) + work_loop_config = btw_config.get("work_loop", {}) + work_loop_config = ( + work_loop_config if isinstance(work_loop_config, dict) else {} + ) + self.conversation_provider_id = conversation_loop_config.get("provider_id", "") + if not isinstance(self.conversation_provider_id, str): + self.conversation_provider_id = "" + self.work_provider_id = work_loop_config.get("provider_id", "") + if not isinstance(self.work_provider_id, str): + self.work_provider_id = "" + self.work_computer_use_runtime = work_loop_config.get( + "computer_use_runtime", "inherit" + ) + if self.work_computer_use_runtime not in { + "inherit", + "none", + "local", + "sandbox", + }: + self.work_computer_use_runtime = "inherit" + self.conv_manager = ctx.execution_context.conversation_manager self.main_agent_cfg, self.max_step = local_agent_runtime_from_profile( conf, + btw_plugin_routes=( + btw_config.get("plugin_routes", []) + if isinstance(btw_config, dict) + else [] + ), + btw_mcp_routes=( + btw_config.get("mcp_routes", []) if isinstance(btw_config, dict) else [] + ), + btw_skill_routes=( + btw_config.get("skill_routes", []) + if isinstance(btw_config, dict) + else [] + ), + conversation_provider_id=self.conversation_provider_id, + work_provider_id=self.work_provider_id, + work_computer_use_runtime=self.work_computer_use_runtime, timezone=self.ctx.execution_context.get_config().get("timezone"), ) self.tool_call_timeout = self.main_agent_cfg.tool_call_timeout @@ -267,11 +315,65 @@ async def _build_checked_agent_runner( event: AstrMessageEvent, streaming_response: bool, ) -> MainAgentBuildResult | None: - """Build a runner and reject configured provider endpoints unsafe for use.""" + """Build a runner and reject configured provider endpoints unsafe for use. + + With BTW disabled the runner is built from the profile as-is: no + loop-mode override, no conversation hard-isolation — the path matches + upstream master exactly. + """ + if not getattr(self, "btw_enabled", False): + build_cfg = replace( + self.main_agent_cfg, + streaming_response=streaming_response, + btw_enabled=False, + ) + return await self._run_checked_build(event, build_cfg) + + loop_mode = "work" if event.get_extra("btw_loop") == "work" else "conversation" + if loop_mode == "conversation": + computer_use_runtime = "none" + provider_id_override = getattr( + self.main_agent_cfg, "conversation_provider_id", "" + ) + else: + computer_use_runtime = getattr( + self.main_agent_cfg, "work_computer_use_runtime", "inherit" + ) + if computer_use_runtime == "inherit": + computer_use_runtime = getattr( + self.main_agent_cfg, "computer_use_runtime", None + ) + if computer_use_runtime not in {"none", "local", "sandbox"}: + computer_use_runtime = "none" + provider_id_override = getattr(self.main_agent_cfg, "work_provider_id", "") + + configured_provider_settings = getattr( + self.main_agent_cfg, "provider_settings", {} + ) + provider_settings = ( + dict(configured_provider_settings) + if isinstance(configured_provider_settings, dict) + else {} + ) + provider_settings["computer_use_runtime"] = computer_use_runtime + build_cfg = replace( self.main_agent_cfg, streaming_response=streaming_response, + loop_mode=loop_mode, + provider_id_override=provider_id_override, + computer_use_runtime=computer_use_runtime, + provider_settings=provider_settings, + btw_enabled=True, ) + return await self._run_checked_build(event, build_cfg) + + async def _run_checked_build( + self, + event: AstrMessageEvent, + build_cfg: MainAgentBuildConfig, + ) -> MainAgentBuildResult | None: + """Run the shared build + blocked-host check for one build config.""" build_result = await build_main_agent( event=event, plugin_context=self.ctx.execution_context, @@ -301,6 +403,10 @@ async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: follow_up_activated = False typing_requested = False try: + # BTW work-loop tasks may detach from the originating event so the + # parent task retains the follow-up runner; the streaming choice is + # still resolved via the unified session override helper below. + is_detached_work = bool(event.get_extra("btw_detached_work")) from astrbot.core.streaming_override import resolve_streaming_response streaming_response = await resolve_streaming_response( @@ -356,7 +462,9 @@ async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: logger.debug("ready to request llm provider") follow_up_capture = ( - self.ctx.execution_context.follow_up_coordinator.try_capture(event) + None + if is_detached_work + else self.ctx.execution_context.follow_up_coordinator.try_capture(event) ) if follow_up_capture: ( @@ -393,6 +501,12 @@ async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: concurrent, lock_key, turn_cm, streaming_response = ( self._prepare_group_sender_concurrency(event, streaming_response) ) + # BTW work-loop tasks share one agent lock across related turns; + # honor the loop-provided key when present, else keep the group + # sender lock key resolved above. + btw_lock_key = event.get_extra("btw_agent_lock_key") + if isinstance(btw_lock_key, str) and btw_lock_key: + lock_key = btw_lock_key async with ( turn_cm, @@ -469,8 +583,11 @@ async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: ) else: runner_stop_callback = None - self._register_follow_up_runner(event, agent_runner, concurrent) - runner_registered = True + # BTW detached work tasks are managed by their parent turn + # and must not register their own follow-up runner. + if not is_detached_work: + self._register_follow_up_runner(event, agent_runner, concurrent) + runner_registered = True event.trace.record( "astr_agent_prepare", system_prompt=req.system_prompt, diff --git a/astrbot/core/pipeline/process_stage/stage.py b/astrbot/core/pipeline/process_stage/stage.py index 51e54642be..729de30f28 100644 --- a/astrbot/core/pipeline/process_stage/stage.py +++ b/astrbot/core/pipeline/process_stage/stage.py @@ -1,5 +1,7 @@ -from collections.abc import AsyncGenerator +import asyncio +from collections.abc import AsyncGenerator, Awaitable, Callable +from astrbot.core.agent.conversation_loop import ConversationLoop from astrbot.core.agent.llm_types import ProviderRequest from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.star.star_handler import StarHandlerMetadata @@ -15,14 +17,43 @@ async def initialize(self, ctx: PipelineContext) -> None: self.ctx = ctx self.config = ctx.astrbot_config - # initialize agent sub stage - self.agent_sub_stage = AgentRequestSubStage() - await self.agent_sub_stage.initialize(ctx) + btw = self.config.get("btw", {}) + btw = btw if isinstance(btw, dict) else {} + self._btw_enabled = bool(btw.get("enabled", False)) + if self._btw_enabled: + # BTW dual-loop mode: the ConversationLoop classifies and + # dispatches work requests over the same Agent sub-stage. + self.conversation_loop = ConversationLoop() + await self.conversation_loop.initialize(ctx) + self.conversation_loop.expose_to_commands(ctx.astrbot_config_id) + self._agent_request = self.conversation_loop.agent_request + else: + # BTW disabled: ProcessStage holds the current Agent sub-stage + # directly, exactly as upstream master does — no wrapper. + self.conversation_loop = None + self._agent_request = AgentRequestSubStage() + await self._agent_request.initialize(ctx) # initialize star request sub stage self.star_request_sub_stage = StarRequestSubStage() await self.star_request_sub_stage.initialize(ctx) + def configure_detached_work( + self, + *, + background_tasks: set[asyncio.Task], + result_dispatcher: Callable[[AstrMessageEvent], Awaitable[None]], + event_finalizer: Callable[[AstrMessageEvent], Awaitable[None]], + ) -> None: + """Give the BTW work loop lifecycle-owned background services.""" + if self.conversation_loop is None: + return + self.conversation_loop.configure_detached_work( + background_tasks=background_tasks, + result_dispatcher=result_dispatcher, + event_finalizer=event_finalizer, + ) + async def process( self, event: AstrMessageEvent, @@ -41,7 +72,7 @@ async def process( handled_plugin_provider_request = True event.set_extra("provider_request", resp) _t = False - async for _ in self.agent_sub_stage.process(event): + async for _ in self._agent_request.process(event): _t = True yield if not _t: @@ -64,5 +95,5 @@ async def process( if ( event.get_result() and not event.is_stopped() ) or not event.get_result(): - async for _ in self.agent_sub_stage.process(event): + async for _ in self._agent_request.process(event): yield diff --git a/astrbot/core/pipeline/scheduler.py b/astrbot/core/pipeline/scheduler.py index 0cf797aca5..154489af98 100644 --- a/astrbot/core/pipeline/scheduler.py +++ b/astrbot/core/pipeline/scheduler.py @@ -8,13 +8,22 @@ from .bootstrap import builtin_stage_classes from .context import PipelineContext +from .result_decorate.stage import ResultDecorateStage from .stage import Stage class _EmptyCompletionEvent(Protocol): """An adapter event that accepts an empty completion signal.""" - def send(self, message: MessageChain | None) -> Awaitable[object]: ... + def send(self, message: MessageChain | None) -> Awaitable[object]: + """Protocol stub: only adapters whose send accepts None match. + + The body raises so the statement is effectful (CodeQL + py/ineffectual-statement); the unreachable ``...`` keeps the stub + form for the type checker. + """ + raise NotImplementedError + ... # unreachable stub marker for the type checker class PipelineScheduler: @@ -33,6 +42,48 @@ async def initialize(self) -> None: stage_instance = stage_cls() # 创建实例 await stage_instance.initialize(self.ctx) self.stages.append(stage_instance) + for stage in self.stages: + configure_detached_work = getattr(stage, "configure_detached_work", None) + if callable(configure_detached_work): + configure_detached_work( + background_tasks=self.ctx.execution_context.background_tasks, + result_dispatcher=self.deliver_detached_result, + event_finalizer=self.finalize_detached_event, + ) + + async def deliver_detached_result(self, event: AstrMessageEvent) -> None: + """Run the configured decoration and response stages for detached work. + + Replays from the first result-decorate stage onward — the decorate + stage's reply content-safety check, TTS/T2I decoration, and the send + stage. Inbound stages (waking, rate limit, inbound content-safety) + already ran for the originating message and are not re-run. + """ + result_stage_index = next( + ( + index + for index, stage in enumerate(self.stages) + if isinstance(stage, ResultDecorateStage) + ), + None, + ) + if result_stage_index is None: + raise RuntimeError("ResultDecorateStage is not configured") + for stage in self.stages[result_stage_index:]: + coroutine = stage.process(event) + if isinstance(coroutine, AsyncGenerator): + async for _ in coroutine: + pass + else: + _ = await coroutine + if event.is_stopped(): + return + + async def finalize_detached_event(self, event: AstrMessageEvent) -> None: + """Release an event retained while its detached work is running.""" + event.set_extra("btw_detached_work_finished", True) + event.cleanup_temporary_local_files() + self.ctx.execution_context.active_event_registry.unregister(event) async def _process_stages(self, event: AstrMessageEvent, from_stage=0) -> None: """依次执行各个阶段 @@ -112,5 +163,8 @@ async def execute(self, event: AstrMessageEvent) -> None: else: logger.debug("pipeline execution completed.") finally: - event.cleanup_temporary_local_files() - self.ctx.execution_context.active_event_registry.unregister(event) + if event.get_extra("btw_detached_work", False): + logger.debug("deferred event cleanup until BTW work finishes") + else: + event.cleanup_temporary_local_files() + self.ctx.execution_context.active_event_registry.unregister(event) diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index c746365c50..410a0fd072 100644 --- a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css +++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css @@ -1,4 +1,4 @@ -/* Auto-generated MDI subset – 272 icons */ +/* Auto-generated MDI subset – 273 icons */ /* Do not edit manually. Run: pnpm run subset-icons */ @font-face { @@ -652,6 +652,10 @@ content: "\F16A8"; } +.mdi-lock-off-outline::before { + content: "\F1672"; +} + .mdi-lock-outline::before { content: "\F0341"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index 059a3ddc92315429fd8651c2d138cb85962a02a0..f0fa6ac0083af491098105aa55ecdae040ac4180 100644 GIT binary patch delta 17663 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 delta 17425 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 U0zw9c|6o1?02&Md&yy2Lg@E#0ZU6uP diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 index 10dc9ebf3b0686189194ee350392fbb6a66d683a..430e65cb43addfbfd19ddc50fae1ec26ea831232 100644 GIT binary patch literal 15048 zcmV;(Iyc34Pew8T0RR9106NG33jhEB0Eu`206KO60RR9100000000000000000000 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 diff --git a/dashboard/src/components/shared/CapabilityLoopSelector.vue b/dashboard/src/components/shared/CapabilityLoopSelector.vue new file mode 100644 index 0000000000..89ea8603b7 --- /dev/null +++ b/dashboard/src/components/shared/CapabilityLoopSelector.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/dashboard/src/components/shared/ConfigItemRenderer.vue b/dashboard/src/components/shared/ConfigItemRenderer.vue index a77382a606..26585f0185 100644 --- a/dashboard/src/components/shared/ConfigItemRenderer.vue +++ b/dashboard/src/components/shared/ConfigItemRenderer.vue @@ -63,6 +63,26 @@ @update:model-value="emitUpdate" /> + + + @@ -306,6 +326,8 @@ import ProviderSelector from './ProviderSelector.vue'; import PersonaSelector from './PersonaSelector.vue'; import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'; import PluginSetSelector from './PluginSetSelector.vue'; +import PluginLoopSelector from './PluginLoopSelector.vue'; +import CapabilityLoopSelector from './CapabilityLoopSelector.vue'; import T2ITemplateEditor from './T2ITemplateEditor.vue'; import DashboardTotpManager from './DashboardTotpManager.vue'; import { computed, ref } from 'vue'; diff --git a/dashboard/src/components/shared/PluginLoopSelector.vue b/dashboard/src/components/shared/PluginLoopSelector.vue new file mode 100644 index 0000000000..005f5541d2 --- /dev/null +++ b/dashboard/src/components/shared/PluginLoopSelector.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index e6bda28fb3..d44b463c0e 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1170,6 +1170,63 @@ "description": "Available Plugins", "hint": "All non-disabled plugins are enabled by default. If a plugin is disabled on the plugins page, selections here will not take effect." } + }, + "btw": { + "description": "BTW dual loops", + "btw": { + "enabled": { + "description": "Enable BTW dual loops", + "hint": "Experimental prototype, disabled by default. When enabled, the conversation loop receives all messages and explicit work requests go to the work loop; high-risk tool actions remain denied from IM per the upstream rules." + }, + "classifier": { + "enabled": { + "description": "Enable task classification", + "hint": "Optional heuristic rules, disabled by default. When enabled, messages matching built-in rules are routed to the work loop; the /work command does not depend on this switch." + } + }, + "conversation_loop": { + "provider_id": { + "description": "Conversation-loop model", + "hint": "Leave empty to use the current session's default chat model. The conversation loop never receives local, sandbox, or filesystem tools." + } + }, + "work_loop": { + "enabled": { + "description": "Enable work loop", + "hint": "When disabled, the conversation loop handles every request." + }, + "provider_id": { + "description": "Work-loop model", + "hint": "Leave empty to use the current session's default chat model. When set, it takes priority over a session model selection." + }, + "computer_use_runtime": { + "description": "Work-loop computer permission", + "hint": "inherit uses the existing computer-use setting; local and sandbox are exposed only to the work loop." + }, + "max_concurrent": { + "description": "Maximum concurrent work loops", + "hint": "Number of work tasks that may execute concurrently in this configuration profile." + } + }, + "work_session": { + "max_age_seconds": { + "description": "Work-session retention", + "hint": "How many seconds to retain completed, failed, or cancelled work for status queries." + } + }, + "plugin_routes": { + "description": "Plugin tool loop assignments", + "hint": "Plugin LLM tools default to the work loop; explicitly assign an enabled plugin to the conversation loop or both when needed." + }, + "mcp_routes": { + "description": "MCP server loop assignments", + "hint": "MCP tools default to the work loop; explicitly assign an enabled server to the conversation loop or both when needed." + }, + "skill_routes": { + "description": "Skill loop assignments", + "hint": "Skills default to both loops; explicitly restrict an enabled Skill to one loop when needed." + } + } } }, "ext_group": { @@ -2069,5 +2126,8 @@ "documentation": "in-app documentation", "helpPrefix": "Don't understand the configuration? See the", "helpSuffix": "." + }, + "btw": { + "name": "BTW dual loops" } } diff --git a/dashboard/src/i18n/locales/en-US/features/config.json b/dashboard/src/i18n/locales/en-US/features/config.json index 828c1c950b..8a5d1f44fb 100644 --- a/dashboard/src/i18n/locales/en-US/features/config.json +++ b/dashboard/src/i18n/locales/en-US/features/config.json @@ -186,6 +186,26 @@ "fileCount": "Files: {count}", "done": "Done" }, + "pluginLoopSelector": { + "hint": "Plugin LLM tools default to Work only. You can explicitly allow Conversation only or both loops; plugin commands are outside this tool route.", + "plugin": "Plugin", + "loop": "Available loop", + "conversation": "Conversation only", + "work": "Work only", + "both": "Conversation and Work", + "empty": "There are no enabled non-system plugins." + }, + "capabilityLoopSelector": { + "mcpHint": "MCP tools default to Work only. Expose a server to the conversation loop only when it is appropriate for chat-time use.", + "skillHint": "Skills default to both loops. Workspace Skills remain available only to the work loop.", + "capability": "Capability", + "loop": "Available loop", + "conversation": "Conversation only", + "work": "Work only", + "both": "Conversation and Work", + "emptyMcp": "There are no enabled MCP servers.", + "emptySkill": "There are no enabled Skills." + }, "unsavedChangesWarning": { "dialogTitle": "Unsaved changes", "leavePage": "You have unsaved changes. Do you want to save before leaving?", diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 48b8d3c979..2c84a1081e 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1164,6 +1164,63 @@ "description": "可用插件", "hint": "默认启用全部未被禁用的插件。若插件在插件页面被禁用,则此处的选择不会生效。" } + }, + "btw": { + "description": "BTW 双循环", + "btw": { + "enabled": { + "description": "启用 BTW 双循环", + "hint": "实验性原型,默认关闭。开启后由对话循环统一接收消息,并将显式工作请求转入工作循环;高风险工具动作仍然按上游规则拒绝,IM 不提权。" + }, + "classifier": { + "enabled": { + "description": "启用任务分类", + "hint": "可选的启发式规则,默认关闭。开启后按内置规则把疑似工作请求转入工作循环;/work 指令不依赖此开关。" + } + }, + "conversation_loop": { + "provider_id": { + "description": "对话循环模型", + "hint": "留空时使用当前会话的默认对话模型。对话循环不会获得本地、沙盒或文件工具。" + } + }, + "work_loop": { + "enabled": { + "description": "启用工作循环", + "hint": "关闭后,所有请求都由对话循环处理。" + }, + "provider_id": { + "description": "工作循环模型", + "hint": "留空时使用当前会话的默认对话模型。配置后会优先于会话模型选择。" + }, + "computer_use_runtime": { + "description": "工作循环电脑权限", + "hint": "inherit 使用现有电脑使用配置;local 和 sandbox 只会暴露给工作循环。" + }, + "max_concurrent": { + "description": "工作循环最大并发数", + "hint": "同一配置文件中可同时执行的工作任务数量。" + } + }, + "work_session": { + "max_age_seconds": { + "description": "工作会话保留时长", + "hint": "已完成、失败或取消的工作任务保留多少秒以供状态查询。" + } + }, + "plugin_routes": { + "description": "插件工具循环分配", + "hint": "插件 LLM 工具默认仅在工作循环可用;可为每个已启用插件显式改为对话循环或两者。" + }, + "mcp_routes": { + "description": "MCP 服务器循环分配", + "hint": "MCP 工具默认仅在工作循环可用;可为每个已启用服务器显式改为对话循环或两者。" + }, + "skill_routes": { + "description": "Skills 循环分配", + "hint": "Skill 默认注入两个循环;可为每个已启用 Skill 显式限制到单一循环。" + } + } } }, "ext_group": { @@ -2059,5 +2116,8 @@ "documentation": "内置文档", "helpPrefix": "不了解配置?请见", "helpSuffix": "。" + }, + "btw": { + "name": "BTW 双循环" } } diff --git a/dashboard/src/i18n/locales/zh-CN/features/config.json b/dashboard/src/i18n/locales/zh-CN/features/config.json index 4704a0ecd6..517b10d4f1 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config.json @@ -186,6 +186,26 @@ "fileCount": "文件:{count}", "done": "完成" }, + "pluginLoopSelector": { + "hint": "插件 LLM 工具默认仅在工作循环可用。可显式改为仅对话循环或两个循环;插件命令不受此工具路由控制。", + "plugin": "插件", + "loop": "可用循环", + "conversation": "仅对话循环", + "work": "仅工作循环", + "both": "对话与工作循环", + "empty": "当前没有已启用的非系统插件。" + }, + "capabilityLoopSelector": { + "mcpHint": "MCP 工具默认仅在工作循环可用。仅在确认服务器适合聊天调用时,才显式开放给对话循环。", + "skillHint": "Skill 默认注入两个循环;工作区 Skill 仍仅在工作循环中可用。", + "capability": "能力", + "loop": "可用循环", + "conversation": "仅对话循环", + "work": "仅工作循环", + "both": "对话与工作循环", + "emptyMcp": "当前没有已启用的 MCP 服务器。", + "emptySkill": "当前没有已启用的 Skill。" + }, "unsavedChangesWarning": { "dialogTitle": "未保存的更改", "leavePage": "当前配置有未保存的更改,切换前是否保存?", diff --git a/dashboard/tests/capabilityLoopSelector.vitest.ts b/dashboard/tests/capabilityLoopSelector.vitest.ts new file mode 100644 index 0000000000..80bbfc3eb7 --- /dev/null +++ b/dashboard/tests/capabilityLoopSelector.vitest.ts @@ -0,0 +1,91 @@ +import { flushPromises } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import CapabilityLoopSelector from '@/components/shared/CapabilityLoopSelector.vue'; +import { mountWithVuetify } from './utils/mountWithVuetify'; + +const testState = vi.hoisted(() => ({ + mcpListMock: vi.fn(), + skillListMock: vi.fn(), +})); + +vi.mock('@/api/v1', () => ({ + mcpApi: { + list: testState.mcpListMock, + }, + skillApi: { + list: testState.skillListMock, + }, +})); + +describe('CapabilityLoopSelector', () => { + beforeEach(() => { + testState.mcpListMock.mockResolvedValue({ + data: { + status: 'ok', + data: [ + { name: 'workspace-mcp', active: true }, + { name: 'disabled-mcp', active: false }, + ], + }, + }); + testState.skillListMock.mockResolvedValue({ + data: { + status: 'ok', + data: { + skills: [ + { name: 'workspace-skill', active: true }, + { name: 'disabled-skill', active: false }, + ], + }, + }, + }); + }); + + it('defaults MCP servers to work and preserves an explicit both override', async () => { + const wrapper = mountWithVuetify(CapabilityLoopSelector, { + props: { + kind: 'mcp', + modelValue: [], + }, + }); + + await flushPromises(); + + expect(wrapper.text()).toContain('workspace-mcp'); + expect(wrapper.text()).not.toContain('disabled-mcp'); + + const select = wrapper.findComponent({ name: 'VSelect' }); + expect(select.props('modelValue')).toBe('work'); + + select.vm.$emit('update:modelValue', 'both'); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('update:modelValue')).toEqual([ + [[{ server_name: 'workspace-mcp', loop: 'both' }]], + ]); + wrapper.unmount(); + }); + + it('uses Skill names as the assignment key', async () => { + const wrapper = mountWithVuetify(CapabilityLoopSelector, { + props: { + kind: 'skill', + modelValue: [], + }, + }); + + await flushPromises(); + + expect(wrapper.text()).toContain('workspace-skill'); + expect(wrapper.text()).not.toContain('disabled-skill'); + + const select = wrapper.findComponent({ name: 'VSelect' }); + select.vm.$emit('update:modelValue', 'conversation'); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('update:modelValue')).toEqual([ + [[{ skill_name: 'workspace-skill', loop: 'conversation' }]], + ]); + wrapper.unmount(); + }); +}); diff --git a/dashboard/tests/pluginLoopSelector.vitest.ts b/dashboard/tests/pluginLoopSelector.vitest.ts new file mode 100644 index 0000000000..40c3501fab --- /dev/null +++ b/dashboard/tests/pluginLoopSelector.vitest.ts @@ -0,0 +1,73 @@ +import { flushPromises } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import PluginLoopSelector from '@/components/shared/PluginLoopSelector.vue'; +import { mountWithVuetify } from './utils/mountWithVuetify'; + +const testState = vi.hoisted(() => ({ + pluginListMock: vi.fn(), +})); + +vi.mock('@/api/v1', () => ({ + pluginApi: { + list: testState.pluginListMock, + }, +})); + +describe('PluginLoopSelector', () => { + beforeEach(() => { + testState.pluginListMock.mockResolvedValue({ + data: { + status: 'ok', + data: [ + { + name: 'example-plugin', + root_dir_name: 'example-plugin', + display_name: 'Example Plugin', + activated: true, + reserved: false, + }, + { + name: 'system-plugin', + activated: true, + reserved: true, + }, + ], + }, + }); + }); + + it('defaults plugin tools to work and preserves an explicit both override', async () => { + const wrapper = mountWithVuetify(PluginLoopSelector, { + props: { + modelValue: [], + }, + }); + + await flushPromises(); + + expect(wrapper.text()).toContain('Example Plugin'); + expect(wrapper.text()).not.toContain('system-plugin'); + + const select = wrapper.findComponent({ name: 'VSelect' }); + expect(select.props('modelValue')).toBe('work'); + + select.vm.$emit('update:modelValue', 'both'); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('update:modelValue')).toEqual([ + [[{ plugin_id: 'example-plugin', loop: 'both' }]], + ]); + + await wrapper.setProps({ + modelValue: [{ plugin_id: 'example-plugin', loop: 'both' }], + }); + select.vm.$emit('update:modelValue', 'work'); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('update:modelValue')).toEqual([ + [[{ plugin_id: 'example-plugin', loop: 'both' }]], + [[]], + ]); + wrapper.unmount(); + }); +}); diff --git a/docs/en/dev/astrbot-config.md b/docs/en/dev/astrbot-config.md index 79dfd52314..6ee03be284 100644 --- a/docs/en/dev/astrbot-config.md +++ b/docs/en/dev/astrbot-config.md @@ -37,6 +37,7 @@ At startup, AstrBot recursively inserts missing current defaults, fixes key orde | `agent_runner` | Agent Runner type and inline configuration for this profile. | | `provider_settings` | Shared AI switch, retrieval, streaming, and Computer Use behavior for this profile. | | `subagent_orchestrator` | SubAgent handoff orchestration. | +| `btw` | Conversation-loop entry point, rule-based task classification, work loop, and plugin/MCP/Skill loop assignments. | | `provider_stt_settings` / `provider_tts_settings` | Default speech-to-text and text-to-speech models and switches. | | `provider_ltm_settings` | [Group chat context awareness](../use/group-chat-context) (in-memory group context, image captions, persisted group history). The JSON key is still historical; it is not the Alkaid long-term-memory switch. Random group proactive replies have been removed. | | `content_safety` | Built-in keyword checks and optional external content-safety checks. | @@ -51,7 +52,7 @@ Object layouts inside `provider_sources`, `provider`, and `platform` come from t ## Inbound routing -User-facing steps are in [When the bot replies in groups](../use/group-wake). `command_prefixes` and `llm_access` are read from the configuration profile selected for the event. `command_prefixes` only frames command headers; it is never combined with an LLM prefix. Each `llm_access.prefixes` entry is the complete string users type, uses token-boundary matching, and follows longest-match semantics. Non-empty LLM prefixes reserve their first command-root token in the same profile, so a prefix that conflicts with an enabled command is rejected by the Dashboard. +`command_prefixes` and `llm_access` are read from the configuration profile selected for the event. `command_prefixes` only frames command headers; it is never combined with an LLM prefix. Each `llm_access.prefixes` entry is the complete string users type, uses token-boundary matching, and follows longest-match semantics. Non-empty LLM prefixes reserve their first command-root token in the same profile, so a prefix that conflicts with an enabled command is rejected by the Dashboard. | Key | Values | Meaning | | ------------------------------------ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | @@ -186,6 +187,30 @@ Local mode operates directly on the AstrBot host and belongs only in a trusted e `image_compress_enabled` and `image_compress_options.max_size/quality` control image compression before model requests. `max_quoted_fallback_images` and `quoted_message_parser` limit quoted and forwarded-message expansion to prevent unbounded fetching. For `quoted_message_parser`, `0` is a valid boundary: depth limits keep the root level but stop child recursion, and `max_forward_fetch=0` disables recursive `get_forward_msg` calls. Negative or invalid values fall back to defaults; this setting does not globally disable a direct quoted-message `get_msg` fallback. +## BTW dual-loop prototype + +`btw` provides one entry point for the current dual-loop prototype. Every message first enters the conversation loop. With rule-based classification enabled, requests about code, files, commands, search, research, or coding agents such as Claude Code, Codex, OpenCode, and HAPI, plus requests beginning with `/work`, are sent to the work loop. The work loop reuses the established Agent and tool execution path; core does not provide a dedicated Codex, CC, or other coding-agent executor. The source-built Docker image does preinstall the `claude` and `codex` CLIs, but they are callable only through work-loop shell tools or an external plugin. + +- `btw.enabled` is the master switch. When disabled, every request still uses the existing Agent path through the conversation loop. +- `btw.classifier.enabled` enables the built-in deterministic rules. When disabled, requests are not automatically sent to the work loop. +- `btw.conversation_loop.provider_id` selects the conversation-loop model. Empty uses the session default; a value takes precedence over a session model selection. +- `btw.work_loop.enabled` enables the work loop; `max_concurrent` limits classified work tasks that can execute at the same time in this profile. +- `btw.work_loop.provider_id` selects the work-loop model, which may differ from the conversation Provider. Empty uses the session default. +- `btw.work_loop.computer_use_runtime` controls computer permission for the work loop. `inherit` uses the existing `provider_settings.computer_use_runtime`; `none`, `local`, and `sandbox` set it explicitly. +- The work loop receives no IM elevation: high-risk `tool.*` actions stay Dashboard-only from IM even when the work loop runs. Privilege isolation is configured per profile through `computer_use_runtime`; the conversation loop hard-disables these tools, and the work loop's runtime choice (`none`, `local`, `sandbox`) is the only control plane. +- `btw.work_session.max_age_seconds` is the retention period for terminal work sessions. It defaults to `3600` seconds and is cleaned up lazily by the next session operation. +- `btw.plugin_routes` lets you choose **Conversation only**, **Work only**, or **Conversation and Work** for every enabled non-system plugin on the **Config** page. No saved entry defaults to **Work only**; choosing both loops is stored as an explicit override. +- `btw.mcp_routes` uses the same choice for every enabled MCP server. No saved entry also defaults to **Work only**, so execution-oriented servers such as `mcp__codex__codex` do not silently enter the conversation loop. +- `btw.skill_routes` uses the same choice for every enabled Skill. Ordinary Skills default to both loops, while workspace Skills remain work-loop-only. + +The conversation loop forcibly disables local computer, sandbox, browser, and filesystem tools. Only the work loop can receive those capabilities. Plugin assignments filter plugin LLM tools, MCP assignments filter all tools provided by each MCP server, and Skill assignments filter which Skill prompts are injected. Existing subagent handoffs receive the same tool routes and cannot regain computer tools from the conversation loop. LLM tools registered by external Claude Code, Self Code, HAPI, Codex app-server, and OpenCode plugins therefore default to the work loop. + +Plugin Pipeline/Star handlers and explicit commands such as `/hapi`, `/codexdev`, `/vibe`, and `/oc` retain the plugin's existing priority and are outside LLM tool routing. Moving those commands into detached work sessions requires explicit plugin support or a future command-execution protocol; a work-only plugin tool assignment does not migrate the entire plugin. + +The work loop first replies that the task has started, then continues in a runtime-owned background task. Its results replay the result-decoration stage onward, which includes the reply content-safety check, TTS/T2I decoration, and platform delivery; inbound stages (waking, rate limit, inbound content safety) are not re-run. Background work uses a separate session lock, so it does not block later chat in the same session. Work sessions are runtime-only in-memory state; query them with the `/work status` command. The state is not retained after a restart or runtime rebuild. + +These settings belong to a configuration profile. Check the BTW switches, concurrency, and plugin-tool assignments separately for every profile. + ## SubAgents, speech, and knowledge base - `subagent_orchestrator.main_enable` enables handoffs. @@ -231,7 +256,7 @@ Dashboard accounts have stable `account_id` values. Their TOTP secret, recovery- - Providers and platforms use three-state `proxy_mode`: `inherit` follows the global config, `direct` disables environment proxies, and `custom` uses only that item's `proxy_url`. An empty string no longer means both inherit and direct. - No GitHub mirrors are provided by default. Plugin `download_url` values and prefix mirrors must be public HTTPS origins; private and non-HTTPS targets are rejected. - `platform_settings.segmented_reply` remains a UX feature and stays off by default. Telegram, Discord, and WeCom hard-limit splitting is handled by the send path. -- `log_level` and `log_file_*` control the console Loguru sink, the root logger, plugin loggers without an override, and rotating file logs. `log_level` applies to terminal output, not only the file sink. File logs use the same redacting sink: recognized secret fields, Bearer tokens, URLs, and absolute paths are replaced before write. Cookies, private chat, and custom secrets are not guaranteed; review logs before sharing. +- `log_level` and `log_file_*` control the console Loguru sink, the root logger, plugin loggers without an override, and rotating file logs. `log_level` applies to terminal output, not only the file sink. - `trace_enable` is the Trace collection switch; `trace_log_*` controls its separate rotating file. - `temp_dir_max_size` limits `data/temp` in MiB and defaults to `1024`; a background task removes older files when the limit is exceeded. - `timezone` is an IANA timezone and defaults to `Asia/Shanghai`. diff --git a/docs/en/use/computer.md b/docs/en/use/computer.md index ba65ae11bc..851bb0f49a 100644 --- a/docs/en/use/computer.md +++ b/docs/en/use/computer.md @@ -75,7 +75,7 @@ Computer Use uses the unified authorization service. There is no “Require Astr - `tool.file_write` - `tool.browser_control` -`tool.file_read` is available to current-session members and above, still subject to path limits. `tool.local_exec`, `tool.python_exec`, `tool.file_write`, `tool.browser_control`, and `tool.computer_use` are high-risk: an authenticated Dashboard-driven WebChat may use them only in its current session/config after the WebChat one-time step-up. Global control-plane actions remain Dashboard-only; anonymous WebChat, IM, plugins, agents, and API keys do not inherit Dashboard roles. Sandbox, path, Persona, and declared-tool restrictions still apply. +`tool.file_read` is available to current-session members and above, still subject to path limits. `tool.local_exec`, `tool.python_exec`, `tool.file_write`, `tool.browser_control`, and `tool.computer_use` are high-risk: an authenticated Dashboard-driven WebChat may use them only in its current session/config after the WebChat one-time step-up. Global control-plane actions remain Dashboard-only; anonymous WebChat, IM, plugins, agents, and API keys do not inherit Dashboard roles. IM never inherits any high-risk action: the BTW work loop adds no elevation path, so `tool.*` high-risk actions stay Dashboard-only from IM regardless of configuration. Sandbox, path, Persona, and declared-tool restrictions still apply. In `local` mode, ordinary session members may read: @@ -85,7 +85,7 @@ In `local` mode, ordinary session members may read: - AstrBot temporary directories - `.astrbot` under the system temporary directory -Writes and edits remain limited to the current session workspace and temporary directories. Grant matching actions from the Dashboard [authorization page](/en/use/authorization). `/admin grant` only creates current-session `session_admin`; it does not turn an IM user into a global operator. See [Architecture](/en/dev/architecture#unified-authorization) for the developer model. +Writes and edits remain limited to the current session workspace and temporary directories. Grant matching actions from the Dashboard [authorization page](/en/use/webui#accounts-and-authorization). `/admin grant` only creates current-session `session_admin`; it does not turn an IM user into a global operator. See [Architecture](/en/dev/architecture#unified-authorization) for the developer model. ## Sandbox Mode diff --git a/docs/zh/dev/astrbot-config.md b/docs/zh/dev/astrbot-config.md index b0dc23f45f..58afb67be3 100644 --- a/docs/zh/dev/astrbot-config.md +++ b/docs/zh/dev/astrbot-config.md @@ -37,6 +37,7 @@ WebUI 创建的其他配置档位于 `data/config/abconf_.json`。消息 | `agent_runner` | 当前配置档的 Agent 执行器类型及其内联配置。 | | `provider_settings` | 当前配置档的 AI 开关、检索、流式输出、Computer Use 等共用行为。 | | `subagent_orchestrator` | 子代理 handoff 编排。 | +| `btw` | 对话循环入口、规则任务分类、工作循环,以及插件、MCP、Skill 的循环分配。 | | `provider_stt_settings` / `provider_tts_settings` | 语音转文本和文本转语音默认模型及开关。 | | `provider_ltm_settings` | [群聊上下文感知](../use/group-chat-context)(内存群聊上下文、图片转述、持久化群消息历史)。JSON 键仍为历史名称;不是 Alkaid 长期记忆开关。群聊随机主动回复已移除。 | | `content_safety` | 内置关键词和可选外部内容安全检查。 | @@ -45,13 +46,12 @@ WebUI 创建的其他配置档位于 `data/config/abconf_.json`。消息 | `command_prefixes` | 指令头前缀,默认 ["/"]。 | | `llm_access` | 当前配置档的私聊和群聊 LLM 访问策略;默认 `private=prefix`、`group=prefix`、`prefixes=["/"]`。 | | `inbound_coalesce` | 可选的连续私聊 LLM 消息有界合并,默认关闭。 | -| 其他顶层键 | 管理员、T2I、代理、日志、时区、插件、知识库、Trace 和指标等。 | `provider_sources`、`provider` 和 `platform` 中的对象结构由各类型注册的当前模板决定。不要从旧文档复制对象;在 WebUI 创建后再检查保存结果。模型通过 `provider_source_id` 引用来源,重命名或删除来源时应让 WebUI 同步引用。 ## 入站路由 -用户向步骤见 [群聊何时会理我](../use/group-wake)。`command_prefixes` 和 `llm_access` 都读取事件实际选中的配置档。`command_prefixes` 只负责指令头,不会与 LLM 前缀自动拼接。`llm_access.prefixes` 的每一项都是用户实际输入的完整字符串,按词边界和最长匹配处理。非空 LLM 前缀会在同一配置档占用其第一个指令根;如果与已启用指令冲突,Dashboard 会拒绝保存。 +`command_prefixes` 和 `llm_access` 都读取事件实际选中的配置档。`command_prefixes` 只负责指令头,不会与 LLM 前缀自动拼接。`llm_access.prefixes` 的每一项都是用户实际输入的完整字符串,按词边界和最长匹配处理。非空 LLM 前缀会在同一配置档占用其第一个指令根;如果与已启用指令冲突,Dashboard 会拒绝保存。 | 键 | 可选值 | 说明 | | ------------------------------------ | ------------------------- | ------------------------------------------------------------------------------------------------ | @@ -188,6 +188,30 @@ API Key 属于敏感配置。不要把真实 `cmd_config.json`、截图、日志 `image_compress_enabled` 和 `image_compress_options.max_size/quality` 控制送入模型前的图片压缩。`max_quoted_fallback_images` 与 `quoted_message_parser` 限制引用消息和转发消息展开深度,避免无限抓取。对 `quoted_message_parser` 而言,`0` 是有效边界:深度限制会保留根层并停止子层递归,`max_forward_fetch=0` 会禁止递归调用 `get_forward_msg`。负数或无效值会回退为默认值;该设置不会全局禁止引用消息回退路径中的直接 `get_msg` 调用。 +## BTW 双循环原型 + +`btw` 为当前的双循环原型提供统一入口。所有消息先进入对话循环;启用规则分类后,包含代码、文件、命令、搜索、调研或 Claude Code、Codex、OpenCode、HAPI 等 coding-agent 意图的请求,以及以 `/work` 开头的请求,会转入工作循环。工作循环复用现有 Agent 与工具执行链;核心没有内置 Codex、CC 或其他专用执行器。源码构建的 Docker 镜像虽然预装了 `claude` 和 `codex` CLI,但它们只有通过工作循环的 Shell 工具或外部插件才能被调用。 + +- `btw.enabled`:总开关。关闭后,所有请求仍通过对话循环使用既有 Agent 路径。 +- `btw.classifier.enabled`:启用内置的确定性分类规则;关闭后不会自动转入工作循环。 +- `btw.conversation_loop.provider_id`:对话循环模型。留空时使用会话默认模型;填写后会优先于会话模型选择。 +- `btw.work_loop.enabled`:启用工作循环;`max_concurrent` 限制同一配置档可同时执行的已分类工作任务数。 +- `btw.work_loop.provider_id`:工作循环模型。可与对话循环使用不同 Provider;留空时使用会话默认模型。 +- `btw.work_loop.computer_use_runtime`:工作循环的电脑权限。`inherit` 使用原有 `provider_settings.computer_use_runtime`,也可显式设为 `none`、`local` 或 `sandbox`。 +- 工作循环不会在 IM 内提权:即使工作循环运行,高风险 `tool.*` 动作在 IM 仍按上游规则拒绝。权限隔离通过配置档的 `computer_use_runtime` 控制;对话循环硬性禁用这些工具,工作循环的运行时选择(`none`、`local`、`sandbox`)是唯一控制面。 +- `btw.work_session.max_age_seconds`:终态工作会话保留时间,默认 `3600` 秒;到期后会在下一次会话操作时清理。 +- `btw.plugin_routes`:在 **配置文件** 页为每个已启用的非系统插件选择“仅对话循环”“仅工作循环”或“两者”。未保存条目默认“仅工作循环”;选择“两者”会保存为显式覆盖。 +- `btw.mcp_routes`:为每个已启用 MCP 服务器做相同的循环选择。未保存条目也默认“仅工作循环”,因此 `mcp__codex__codex` 等执行型 MCP 不会自动进入对话循环。 +- `btw.skill_routes`:为每个已启用 Skill 做相同的循环选择。普通 Skill 未保存时默认注入两个循环;工作区 Skill 仍只会注入工作循环。 + +对话循环会强制禁用本地电脑、沙盒、浏览器和文件工具;这些能力只可能由工作循环获得。插件分配过滤插件注册给 LLM 的工具,MCP 分配过滤每个 MCP 服务器提供的全部工具,Skill 分配过滤注入的 Skill 提示。既有的子代理 handoff 也会应用相同的工具分配,且无法在对话循环重新获得电脑工具。Claude Code、Self Code、HAPI、Codex app-server、OpenCode 等外部插件注册的 LLM 工具因此默认只在工作循环可用。 + +插件的 Pipeline/Star 处理器和 `/hapi`、`/codexdev`、`/vibe`、`/oc` 等显式命令仍按插件既有优先级运行,不属于 LLM 工具路由。要让这类插件命令也采用后台工作会话,需要插件侧或后续的命令执行协议显式支持;不要把“插件工具仅工作循环”理解为整个插件都被迁移。 + +工作循环会先回复“工作任务已开始处理”,再由运行时后台任务执行;其结果从结果装饰阶段开始重放,包含回复内容安全检查、TTS/T2I 装饰和平台发送;入站阶段(唤醒、限流、入站内容安全)不会重新执行。后台工作使用与普通对话不同的会话锁,因此不会阻塞同一会话后续的聊天。工作会话是运行时内存状态,通过 `/work status` 指令查询最近一次任务状态;重启或重建运行时后该状态不会保留。 + +这些设置属于配置档。多个配置档时,应分别检查其 BTW 开关、并发数和插件工具分配。 + ## 子代理、语音与知识库 - `subagent_orchestrator.main_enable`:启用 handoff。 @@ -233,7 +257,7 @@ Dashboard 账户有稳定的 `account_id`,其 TOTP 密钥、恢复码哈希和 - Provider / Platform 使用三态 `proxy_mode`:`inherit` 跟随全局配置,`direct` 明确直连并忽略环境变量代理,`custom` 只使用本项 `proxy_url`。空字符串不再同时表示继承和直连。 - GitHub 镜像默认不提供。插件 `download_url` 和镜像前缀必须是公开 HTTPS origin,私网和非 HTTPS 会被拒绝。 - `platform_settings.segmented_reply` 仍是默认关闭的体验分段。Telegram / Discord / 企业微信的平台硬限制分段由发送层负责,二者不要混用。 -- `log_level`、`log_file_*`:控制台 Loguru sink、根 logger、未单独覆盖的插件 logger,以及轮转文件日志。`log_level` 会同步到终端输出,不只写文件。文件日志走同一脱敏出口:已识别的密钥字段、Bearer、URL 和绝对路径会在写入前替换。Cookie、私聊和自定义 secret 不保证被剥离;分享前仍需人工检查。 +- `log_level`、`log_file_*`:控制台 Loguru sink、根 logger、未单独覆盖的插件 logger,以及轮转文件日志。`log_level` 会同步到终端输出,不只写文件。 - `trace_enable`:Trace 采集总开关;`trace_log_*` 控制独立 Trace 文件。 - `temp_dir_max_size`:`data/temp` 上限(MiB),默认 `1024`;后台定期清理旧文件。 - `timezone`:IANA 时区名称,默认 `Asia/Shanghai`。 diff --git a/docs/zh/use/computer.md b/docs/zh/use/computer.md index 5978f0b49d..2890467a41 100644 --- a/docs/zh/use/computer.md +++ b/docs/zh/use/computer.md @@ -73,7 +73,7 @@ data/workspaces/{normalized_umo}/notes/todo.txt - `tool.file_write` - `tool.browser_control` -`tool.file_read` 对当前会话的 member 及以上开放,但仍受路径约束。`tool.local_exec`、`tool.python_exec`、`tool.file_write`、`tool.browser_control` 和 `tool.computer_use` 是高风险动作:已认证 Dashboard 驱动的 WebChat 仅可在当前 session/config 内,经 WebChat 一次性 step-up 后使用;全局控制面仍 Dashboard-only,匿名 WebChat、IM、插件、Agent 和 API Key 一律不会继承 Dashboard 角色。沙箱、路径和 Persona/工具声明限制仍然有效。 +`tool.file_read` 对当前会话的 member 及以上开放,但仍受路径约束。`tool.local_exec`、`tool.python_exec`、`tool.file_write`、`tool.browser_control` 和 `tool.computer_use` 是高风险动作:已认证 Dashboard 驱动的 WebChat 仅可在当前 session/config 内,经 WebChat 一次性 step-up 后使用;全局控制面仍 Dashboard-only,匿名 WebChat、IM、插件、Agent 和 API Key 一律不会继承 Dashboard 角色。IM 一律不继承高风险动作:BTW 工作循环不提供任何提权通道,无论配置如何,IM 内的高风险 `tool.*` 动作都保持 Dashboard-only。沙箱、路径和 Persona/工具声明限制仍然有效。 `local` 模式下,普通会话成员可以读取: @@ -83,7 +83,7 @@ data/workspaces/{normalized_umo}/notes/todo.txt - AstrBot 的临时目录 - 系统临时目录中的 `.astrbot` -写入和编辑仍限制在当前会话 workspace 和临时目录。请通过 Dashboard [权限页面](/use/authorization)授予匹配动作的绑定;`/admin grant` 只授予当前会话 `session_admin`,不能把 IM 用户变成全局 operator。开发模型见[项目架构](/dev/architecture#统一授权系统)。 +写入和编辑仍限制在当前会话 workspace 和临时目录。请通过 Dashboard [权限页面](/use/webui#账户与权限)授予匹配动作的绑定;`/admin grant` 只授予当前会话 `session_admin`,不能把 IM 用户变成全局 operator。开发模型见[项目架构](/dev/architecture#统一授权系统)。 ## Sandbox 模式 diff --git a/tests/unit/test_agent_internal_process.py b/tests/unit/test_agent_internal_process.py index 73d4038015..ea55faa669 100644 --- a/tests/unit/test_agent_internal_process.py +++ b/tests/unit/test_agent_internal_process.py @@ -1,7 +1,7 @@ -from __future__ import annotations - import pytest +from astrbot.core.agent.tool import ToolSet +from astrbot.core.astr_main_agent import MainAgentBuildConfig from astrbot.core.message.components import Json from tests.unit.agent_sub_stage_support import * # noqa: F403 @@ -1194,3 +1194,185 @@ def fail_on_second_create_task(coro, *, name=None): == "Error occurred during AI execution." ) event.stop_typing.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_internal_builder_applies_model_and_permission_per_btw_loop( + monkeypatch, +): + stage = internal.InternalAgentSubStage.__new__(internal.InternalAgentSubStage) + stage.ctx = _pipeline_context(_internal_plugin_context()) + stage.btw_enabled = True + stage.main_agent_cfg = MainAgentBuildConfig( + tool_call_timeout=60, + computer_use_runtime="local", + provider_settings={"computer_use_runtime": "local"}, + conversation_provider_id="conversation-model", + work_provider_id="work-model", + work_computer_use_runtime="sandbox", + btw_mcp_routes=[{"server_name": "workspace", "loop": "work"}], + btw_skill_routes=[{"skill_name": "workspace-edit", "loop": "work"}], + ) + build_result = SimpleNamespace( + provider=SimpleNamespace(provider_config={"api_base": ""}), + ) + build_main_agent = AsyncMock(return_value=build_result) + monkeypatch.setattr(internal, "build_main_agent", build_main_agent) + + conversation_event = FakeEvent() + assert ( + await stage._build_checked_agent_runner( + conversation_event, + streaming_response=True, + ) + is build_result + ) + conversation_config = build_main_agent.await_args.kwargs["config"] + assert conversation_config.loop_mode == "conversation" + assert conversation_config.provider_id_override == "conversation-model" + assert conversation_config.computer_use_runtime == "none" + assert conversation_config.btw_mcp_routes == [ + {"server_name": "workspace", "loop": "work"} + ] + assert conversation_config.btw_skill_routes == [ + {"skill_name": "workspace-edit", "loop": "work"} + ] + + work_event = FakeEvent(extras={"btw_loop": "work"}) + assert ( + await stage._build_checked_agent_runner( + work_event, + streaming_response=False, + ) + is build_result + ) + work_config = build_main_agent.await_args.kwargs["config"] + assert work_config.loop_mode == "work" + assert work_config.provider_id_override == "work-model" + assert work_config.computer_use_runtime == "sandbox" + assert work_config.provider_settings["computer_use_runtime"] == "sandbox" + + +@pytest.mark.asyncio +async def test_internal_builder_btw_disabled_matches_master_path(monkeypatch): + """With BTW disabled the runner build must be upstream-master identical.""" + stage = internal.InternalAgentSubStage.__new__(internal.InternalAgentSubStage) + stage.ctx = _pipeline_context(_internal_plugin_context()) + stage.btw_enabled = False + stage.main_agent_cfg = MainAgentBuildConfig( + tool_call_timeout=60, + computer_use_runtime="local", + provider_settings={"computer_use_runtime": "local"}, + conversation_provider_id="conversation-model", + work_provider_id="work-model", + work_computer_use_runtime="sandbox", + ) + build_result = SimpleNamespace( + provider=SimpleNamespace(provider_config={"api_base": ""}), + ) + build_main_agent = AsyncMock(return_value=build_result) + monkeypatch.setattr(internal, "build_main_agent", build_main_agent) + + event = FakeEvent(extras={"btw_loop": "work"}) + auth_context = SimpleNamespace(metadata={}) + event.auth_context = auth_context + + assert ( + await stage._build_checked_agent_runner(event, streaming_response=False) + is build_result + ) + config = build_main_agent.await_args.kwargs["config"] + assert config.btw_enabled is False + # No loop_mode override is written at all: the profile default passes through. + assert config.loop_mode == "conversation" + assert config.provider_id_override == "" + assert config.computer_use_runtime == "local" + assert config.provider_settings["computer_use_runtime"] == "local" + # No elevation metadata is stamped on the auth context. + assert auth_context.metadata == {} + + +@pytest.mark.asyncio +async def test_disabled_btw_keeps_local_tools_and_workspace_skills(monkeypatch): + """btw_enabled=False + computer_use_runtime=local keeps master behavior. + + The disabled path must still apply local environment tools and still + inject workspace Skills — an operator who never touched BTW must not + lose host capabilities just because this feature ships. + """ + import astrbot.core.astr_main_agent as ama + + req = ProviderRequest(prompt="hello") + plugin_context = SimpleNamespace( + catalogs=SimpleNamespace( + tools=SimpleNamespace(), + plugins=SimpleNamespace(get_by_module=lambda _p: None), + ), + computer_runtime=SimpleNamespace(get_session_booter=lambda _s: None), + get_config=lambda **_kw: {"timezone": "UTC"}, + ) + config = MainAgentBuildConfig( + tool_call_timeout=60, + btw_enabled=False, + loop_mode="conversation", + computer_use_runtime="local", + provider_settings={"computer_use_runtime": "local"}, + timezone="UTC", + ) + + applied_local = MagicMock() + monkeypatch.setattr(ama, "_apply_local_env_tools", applied_local) + applied_sandbox = MagicMock() + monkeypatch.setattr(ama, "_apply_sandbox_tools", applied_sandbox) + + ok = await ama._prepare_request_for_agent( + SimpleNamespace( + message_obj=SimpleNamespace(message=[]), + unified_msg_origin="webchat:FriendMessage:u", + plugins_name=None, + get_extra=lambda _k, default=None: default, + get_platform_id=lambda: "webchat", + ), + req, + plugin_context, + config, + provider=None, + ) + assert ok + applied_local.assert_called_once() + applied_sandbox.assert_not_called() + + # Workspace Skills also stay available when BTW is off. + skill_manager = SimpleNamespace( + list_workspace_skills=MagicMock(return_value=[]), + list_skills=MagicMock(return_value=[]), + ) + plugin_context2 = SimpleNamespace( + skill_manager=skill_manager, + catalogs=SimpleNamespace( + builtin_skills=None, + plugins=SimpleNamespace(get_by_module=lambda _p: None, all=lambda: []), + ), + persona_manager=SimpleNamespace( + resolve_selected_persona=AsyncMock(return_value=("", None, None, False)), + ), + get_llm_tool_manager=lambda: SimpleNamespace( + get_full_tool_set=lambda: ToolSet() + ), + get_config=lambda **_kw: {"timezone": "UTC"}, + subagent_orchestrator=None, + ) + await ama._ensure_persona_and_skills( + ProviderRequest(prompt="x", conversation=SimpleNamespace(persona_id="")), + {"computer_use_runtime": "local"}, + plugin_context2, + SimpleNamespace( + unified_msg_origin="webchat:FriendMessage:u", + get_platform_name=lambda: "webchat", + set_extra=lambda *_a, **_k: None, + get_extra=lambda _k, default=None: default, + ), + loop_mode="conversation", + btw_enabled=False, + ) + skill_manager.list_workspace_skills.assert_called_once() diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index 0ea746c96c..6a674f15cc 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -5,30 +5,39 @@ import mcp import pytest +from mcp.types import Tool from astrbot.core.agent.agent import Agent from astrbot.core.agent.handoff import HandoffTool +from astrbot.core.agent.mcp_client import MCPTool, MCPToolNameAllocator from astrbot.core.agent.run_context import ContextWrapper -from astrbot.core.agent.tool import FunctionTool +from astrbot.core.agent.tool import FunctionTool, ToolSet from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor, call_local_llm_tool from astrbot.core.auth.models import AuthContext, Resource, Subject from astrbot.core.message.components import Image +from astrbot.core.tools.computer_tools import FileReadTool from astrbot.core.tools.function_tool_manager import ( FunctionToolManager, ) class _DummyEvent: - def __init__(self, message_components: list[object] | None = None) -> None: + def __init__( + self, + message_components: list[object] | None = None, + *, + extras: dict | None = None, + ) -> None: self.unified_msg_origin = "webchat:FriendMessage:webchat!user!session" self.message_obj = SimpleNamespace(message=message_components or []) self.role = "member" + self._extras = extras or {} - def get_extra(self, _key: str, default=None): - return default + def get_extra(self, key: str, default=None): + return self._extras.get(key, default) - def set_extra(self, _key: str, _value) -> None: - return None + def set_extra(self, key: str, value) -> None: + self._extras[key] = value class _DummyTool: @@ -224,6 +233,99 @@ def test_build_handoff_toolset_keeps_declared_tools(): assert toolset.get_tool("transfer_to_child") is None +def test_handoff_toolset_defaults_plugin_mcp_and_computer_tools_to_work(): + mcp_tool = MCPTool( + Tool( + name="workspace_mcp", + description="workspace MCP", + inputSchema={"type": "object", "properties": {}}, + ), + AsyncMock(), + "workspace-server", + ) + safe_tool = FunctionTool( + name="safe", + description="safe", + parameters={"type": "object", "properties": {}}, + ) + plugin_tool = FunctionTool( + name="coding_agent", + description="coding agent", + parameters={"type": "object", "properties": {}}, + handler_module_path="plugins.coding.main", + ) + event = _DummyEvent(extras={"btw_loop": "conversation"}) + plugin = SimpleNamespace(root_dir_name="coding", name="coding") + context = SimpleNamespace( + catalogs=SimpleNamespace( + plugins=SimpleNamespace( + get_by_module=lambda module_path: ( + plugin if module_path == "plugins.coding.main" else None + ) + ) + ) + ) + + filtered = FunctionToolExecutor._filter_handoff_toolset_for_btw( + ToolSet([mcp_tool, FileReadTool(), plugin_tool, safe_tool]), + ctx=context, + cfg={"btw": {"enabled": True}}, + event=event, + ) + + assert filtered.names() == ["safe"] + + +def test_handoff_toolset_honors_explicit_both_routes(): + mcp_tool = MCPTool( + Tool( + name="workspace_mcp", + description="workspace MCP", + inputSchema={"type": "object", "properties": {}}, + ), + AsyncMock(), + "workspace-server", + ) + plugin_tool = FunctionTool( + name="coding_agent", + description="coding agent", + parameters={"type": "object", "properties": {}}, + handler_module_path="plugins.coding.main", + ) + event = _DummyEvent(extras={"btw_loop": "conversation"}) + plugin = SimpleNamespace(root_dir_name="coding", name="coding") + context = SimpleNamespace( + catalogs=SimpleNamespace( + plugins=SimpleNamespace( + get_by_module=lambda module_path: ( + plugin if module_path == "plugins.coding.main" else None + ) + ) + ) + ) + + filtered = FunctionToolExecutor._filter_handoff_toolset_for_btw( + ToolSet([mcp_tool, plugin_tool]), + ctx=context, + cfg={ + "btw": { + "mcp_routes": [ + {"server_name": "workspace-server", "loop": "both"}, + ], + "plugin_routes": [ + {"plugin_id": "coding", "loop": "both"}, + ], + } + }, + event=event, + ) + + assert filtered.names() == [ + MCPToolNameAllocator().allocate("workspace-server", "workspace_mcp"), + "coding_agent", + ] + + @pytest.mark.asyncio async def test_collect_handoff_image_urls_normalizes_filters_and_appends_event_image( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 35e0250e46..145eee5a18 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -6,10 +6,11 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from mcp.types import Tool from astrbot.core import astr_main_agent as ama from astrbot.core.agent.llm_types import ProviderRequest -from astrbot.core.agent.mcp_client import MCPTool +from astrbot.core.agent.mcp_client import MCPTool, MCPToolNameAllocator from astrbot.core.agent.message import Message, TextPart, dump_messages_with_checkpoints from astrbot.core.agent.tool import FunctionTool, ToolSet from astrbot.core.conversation_models import Conversation @@ -114,6 +115,223 @@ def test_provider_supports_modality_requires_explicit_list(): assert not ama._provider_supports_modality(provider, "image") +def test_filter_plugin_tools_for_loop_keeps_only_assigned_plugin_tools(): + plugin_tool = FunctionTool( + name="plugin_tool", + description="plugin tool", + parameters={"type": "object", "properties": {}}, + handler_module_path="plugins.example.main", + ) + builtin_tool = FunctionTool( + name="builtin_tool", + description="builtin tool", + parameters={"type": "object", "properties": {}}, + ) + req = ProviderRequest(prompt="test", func_tool=ToolSet([plugin_tool, builtin_tool])) + plugin = SimpleNamespace(root_dir_name="example", name="example") + context = SimpleNamespace( + catalogs=SimpleNamespace( + plugins=SimpleNamespace( + get_by_module=lambda module_path: ( + plugin if module_path == "plugins.example.main" else None + ) + ) + ) + ) + config = ama.MainAgentBuildConfig( + btw_enabled=True, + tool_call_timeout=60, + loop_mode="conversation", + btw_plugin_routes=[{"plugin_id": "example", "loop": "work"}], + ) + + ama._filter_plugin_tools_for_loop(req, context, config) + + assert req.func_tool is not None + assert req.func_tool.names() == ["builtin_tool"] + + +def test_filter_plugin_tools_for_loop_defaults_unassigned_tools_to_work(): + plugin_tool = FunctionTool( + name="plugin_tool", + description="plugin tool", + parameters={"type": "object", "properties": {}}, + handler_module_path="plugins.example.main", + ) + req = ProviderRequest(prompt="test", func_tool=ToolSet([plugin_tool])) + context = SimpleNamespace( + catalogs=SimpleNamespace( + plugins=SimpleNamespace( + get_by_module=lambda module_path: ( + SimpleNamespace(root_dir_name="example", name="example") + if module_path == "plugins.example.main" + else None + ) + ) + ) + ) + config = ama.MainAgentBuildConfig( + btw_enabled=True, tool_call_timeout=60, loop_mode="conversation" + ) + + ama._filter_plugin_tools_for_loop(req, context, config) + + assert req.func_tool is not None + assert req.func_tool.names() == [] + + work_req = ProviderRequest(prompt="test", func_tool=ToolSet([plugin_tool])) + work_config = ama.MainAgentBuildConfig( + btw_enabled=True, tool_call_timeout=60, loop_mode="work" + ) + + ama._filter_plugin_tools_for_loop(work_req, context, work_config) + + assert work_req.func_tool is not None + assert work_req.func_tool.names() == ["plugin_tool"] + + +def test_filter_plugin_tools_for_loop_honors_explicit_both_assignment(): + plugin_tool = FunctionTool( + name="plugin_tool", + description="plugin tool", + parameters={"type": "object", "properties": {}}, + handler_module_path="plugins.example.main", + ) + req = ProviderRequest(prompt="test", func_tool=ToolSet([plugin_tool])) + context = SimpleNamespace( + catalogs=SimpleNamespace( + plugins=SimpleNamespace( + get_by_module=lambda module_path: ( + SimpleNamespace(root_dir_name="example", name="example") + if module_path == "plugins.example.main" + else None + ) + ) + ) + ) + config = ama.MainAgentBuildConfig( + tool_call_timeout=60, + btw_enabled=True, + loop_mode="conversation", + btw_plugin_routes=[{"plugin_id": "example", "loop": "both"}], + ) + + ama._filter_plugin_tools_for_loop(req, context, config) + + assert req.func_tool is not None + assert req.func_tool.names() == ["plugin_tool"] + + +def test_route_filter_uses_capability_default_for_malformed_assignment(): + routes = [{"plugin_id": "coding", "loop": "unexpected"}] + + assert not ama._route_is_available_in_loop( + routes, + route_key="plugin_id", + route_id="coding", + loop_mode="conversation", + default_loop="work", + ) + assert ama._route_is_available_in_loop( + routes, + route_key="plugin_id", + route_id="coding", + loop_mode="work", + default_loop="work", + ) + + +def test_filter_mcp_tools_for_loop_keeps_only_assigned_servers(): + input_schema = {"type": "object", "properties": {}} + conversation_tool = MCPTool( + Tool(name="weather", description="weather", inputSchema=input_schema), + MagicMock(), + "weather-server", + ) + work_tool = MCPTool( + Tool(name="workspace", description="workspace", inputSchema=input_schema), + MagicMock(), + "workspace-server", + ) + req = ProviderRequest( + prompt="test", + func_tool=ToolSet([conversation_tool, work_tool]), + ) + config = ama.MainAgentBuildConfig( + btw_enabled=True, + tool_call_timeout=60, + loop_mode="conversation", + btw_mcp_routes=[ + {"server_name": "weather-server", "loop": "conversation"}, + {"server_name": "workspace-server", "loop": "work"}, + ], + ) + + ama._filter_mcp_tools_for_loop(req, config) + + assert req.func_tool is not None + assert req.func_tool.names() == [ + MCPToolNameAllocator().allocate("weather-server", "weather") + ] + + +def test_filter_mcp_tools_for_loop_defaults_unassigned_servers_to_work(): + input_schema = {"type": "object", "properties": {}} + work_tool = MCPTool( + Tool(name="workspace", description="workspace", inputSchema=input_schema), + MagicMock(), + "workspace-server", + ) + conversation_req = ProviderRequest( + prompt="test", + func_tool=ToolSet([work_tool]), + ) + conversation_config = ama.MainAgentBuildConfig( + btw_enabled=True, + tool_call_timeout=60, + loop_mode="conversation", + ) + + ama._filter_mcp_tools_for_loop(conversation_req, conversation_config) + + assert conversation_req.func_tool is not None + assert conversation_req.func_tool.names() == [] + + work_req = ProviderRequest(prompt="test", func_tool=ToolSet([work_tool])) + work_config = ama.MainAgentBuildConfig( + btw_enabled=True, tool_call_timeout=60, loop_mode="work" + ) + + ama._filter_mcp_tools_for_loop(work_req, work_config) + + assert work_req.func_tool is not None + assert work_req.func_tool.names() == [ + MCPToolNameAllocator().allocate("workspace-server", "workspace") + ] + + +def test_filter_skills_for_loop_keeps_only_assigned_skills(): + skills = [ + SkillInfo( + name="chat-search", description="", path="chat/SKILL.md", active=True + ), + SkillInfo( + name="workspace-edit", + description="", + path="workspace/SKILL.md", + active=True, + ), + ] + + conversation_skills = ama._filter_skills_for_loop( + skills, + [{"skill_name": "workspace-edit", "loop": "work"}], + "conversation", + ) + + assert [skill.name for skill in conversation_skills] == ["chat-search"] + + @pytest.mark.asyncio async def test_prepare_event_attachments_is_idempotent(mock_event, mock_context): req = ProviderRequest() @@ -405,6 +623,21 @@ def test_select_provider_by_id(self, mock_event, mock_context, mock_provider): assert result == mock_provider mock_context.get_provider_by_id.assert_called_once_with("test-provider") + def test_select_provider_prefers_loop_model_override( + self, mock_event, mock_context, mock_provider + ): + mock_event.get_extra.return_value = "session-provider" + mock_context.get_provider_by_id.return_value = mock_provider + + result = ama._select_provider( + mock_event, + mock_context, + provider_id_override="work-provider", + ) + + assert result == mock_provider + mock_context.get_provider_by_id.assert_called_once_with("work-provider") + def test_select_provider_not_found(self, mock_event, mock_context): """Test selecting provider when ID is not found.""" module = ama @@ -1151,7 +1384,11 @@ async def test_ensure_skills_includes_workspace_skills( runtime_config = {"computer_use_runtime": "local"} await module._ensure_persona_and_skills( - req, runtime_config, mock_context, mock_event + req, + runtime_config, + mock_context, + mock_event, + loop_mode="work", ) assert "**workspace-skill**" in req.system_prompt @@ -1361,6 +1598,7 @@ async def test_persona_empty_tools_keeps_local_runtime_builtin_tools( mock_event.platform_meta.support_proactive_message = False config = module.MainAgentBuildConfig( tool_call_timeout=60, + loop_mode="work", computer_use_runtime="local", add_cron_tools=False, ) @@ -1393,6 +1631,53 @@ async def test_persona_empty_tools_keeps_local_runtime_builtin_tools( if result.reset_coro: result.reset_coro.close() + def test_conversation_loop_filters_computer_and_filesystem_tools(self): + req = ProviderRequest( + prompt="hello", + func_tool=ToolSet( + [ + ama.ExecuteShellTool(), + ama.FileReadTool(), + FunctionTool( + name="safe_tool", + description="safe", + parameters={"type": "object", "properties": {}}, + ), + ] + ), + ) + config = ama.MainAgentBuildConfig( + tool_call_timeout=60, + loop_mode="conversation", + computer_use_runtime="local", + btw_enabled=True, + ) + + ama._filter_privileged_tools_for_conversation(req, config) + + assert req.func_tool is not None + assert req.func_tool.names() == ["safe_tool"] + + def test_conversation_loop_hard_isolation_disabled_with_btw_off(self): + """With BTW disabled the Agent path matches master: no tool stripping.""" + req = ProviderRequest( + prompt="hello", + func_tool=ToolSet([ama.ExecuteShellTool(), ama.FileReadTool()]), + ) + config = ama.MainAgentBuildConfig( + tool_call_timeout=60, + loop_mode="conversation", + computer_use_runtime="local", + ) + + ama._filter_privileged_tools_for_conversation(req, config) + + assert req.func_tool is not None + assert req.func_tool.names() == [ + "astrbot_execute_shell", + "astrbot_file_read_tool", + ] + @pytest.mark.asyncio async def test_subagent_dedupe_uses_default_persona_tools( self, mock_event, mock_context diff --git a/tests/unit/test_authorization_service.py b/tests/unit/test_authorization_service.py index daeb0024d2..1c47d29bd4 100644 --- a/tests/unit/test_authorization_service.py +++ b/tests/unit/test_authorization_service.py @@ -1624,6 +1624,68 @@ async def test_high_risk_allow_fails_closed_when_audit_queue_is_full(authorizati assert decision.reason == "audit_unavailable" +@pytest.mark.asyncio +async def test_im_subjects_cannot_bypass_step_up_for_high_risk_tools(authorization): + """IM operators are always denied high-risk tool actions as dashboard-only. + + IM has no step-up path: high-risk ``tool.*`` actions stay Dashboard-only + regardless of any work-loop metadata an event may carry. + """ + subject = Subject.im( + platform_instance="napcat", bot_account_id="bot", sender_id="42" + ) + await authorization.grant_binding( + actor=Subject.system("test"), + subject_id=subject.id, + role=Role.INSTANCE_OPERATOR, + scope_type="instance", + scope_id="default", + config_id="default", + enforce_actor=False, + ) + shell_resource = Resource.named( + "tool", "astrbot_execute_shell", config_id="default" + ) + # Like every real IM event (see waking_check/stage.py), the auth context + # is bound to its inbound session, so the upstream ``origin_session`` + # required-context gate is satisfied before the step-up branch. + session_resource = Resource.session("default", "napcat:FriendMessage:napcat!bot!42") + + def _btw_context(**metadata) -> AuthContext: + return AuthContext( + subject=subject, + source="im", + config_id="default", + authenticated=subject.authenticated, + origin_session_resource_id=session_resource.id, + metadata=metadata, + ) + + # Plain IM operator context. + denied = await authorization.authorize( + subject, + "tool.local_exec", + shell_resource, + _btw_context(), + ) + assert not denied.allowed + assert denied.reason == "high_risk_dashboard_only" + + # Stale work-loop elevation metadata (from a previous release) must not + # lift the deny. + with_metadata = await authorization.authorize( + subject, + "tool.local_exec", + shell_resource, + _btw_context( + btw_work_elevation=True, + btw_elevated_actions=("tool.local_exec",), + ), + ) + assert not with_metadata.allowed + assert with_metadata.reason == "high_risk_dashboard_only" + + @pytest.mark.asyncio async def test_binding_mutations_write_audit_records(authorization): owner = Subject.im(platform_instance="napcat", bot_account_id="bot", sender_id="42") diff --git a/tests/unit/test_btw.py b/tests/unit/test_btw.py new file mode 100644 index 0000000000..2e94c7915c --- /dev/null +++ b/tests/unit/test_btw.py @@ -0,0 +1,224 @@ +import asyncio +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from astrbot.core.agent.btw import ( + TaskClassifier, + TaskType, + WorkLoop, + WorkSessionManager, + WorkSessionStatus, +) + + +class FakeEvent: + def __init__(self, message: str) -> None: + self.unified_msg_origin = "umo-1" + self.message_str = message + self.extras = {} + self.result = None + + def set_extra(self, key, value) -> None: + self.extras[key] = value + + def get_extra(self, key): + return self.extras.get(key) + + def set_result(self, value) -> None: + self.result = value + + +class FailingExecutor: + async def process(self, event): + del event + raise RuntimeError("provider token leaked") + yield + + +class BlockingExecutor: + def __init__(self) -> None: + self.started = asyncio.Event() + self.release = asyncio.Event() + + async def process(self, event): + del event + self.started.set() + await self.release.wait() + yield "done" + + +@pytest.mark.asyncio +async def test_task_classifier_selects_work_for_keywords_and_conversation_otherwise(): + # Keyword heuristics are opt-in: nothing is enabled by default. + disabled = TaskClassifier( + {"btw": {"enabled": True, "work_loop": {"enabled": True}}} + ) + assert ( + await disabled.classify(SimpleNamespace(message_str="帮我修改代码")) + is TaskType.CONVERSATION + ) + assert ( + await disabled.classify(SimpleNamespace(message_str="continue with Codex")) + is TaskType.CONVERSATION + ) + + classifier = TaskClassifier( + { + "btw": { + "enabled": True, + "classifier": {"enabled": True}, + "work_loop": {"enabled": True}, + } + } + ) + + assert ( + await classifier.classify(SimpleNamespace(message_str="你好")) + is TaskType.CONVERSATION + ) + assert ( + await classifier.classify(SimpleNamespace(message_str="帮我修改代码")) + is TaskType.WORK + ) + assert ( + await classifier.classify( + SimpleNamespace(message_str="让 Claude Code 处理这个仓库") + ) + is TaskType.WORK + ) + assert ( + await classifier.classify(SimpleNamespace(message_str="continue with Codex")) + is TaskType.WORK + ) + + +@pytest.mark.asyncio +async def test_task_classifier_defaults_exclude_everyday_queries_and_honor_word_boundaries(): + classifier = TaskClassifier( + { + "btw": { + "enabled": True, + "classifier": {"enabled": True}, + "work_loop": {"enabled": True}, + } + } + ) + + # Broad everyday keywords (search/搜索/查询/research) are not in the + # default set — they classify ordinary questions as conversation. + assert ( + await classifier.classify( + SimpleNamespace(message_str="search for a restaurant") + ) + is TaskType.CONVERSATION + ) + assert ( + await classifier.classify( + SimpleNamespace(message_str="what is the research paper about") + ) + is TaskType.CONVERSATION + ) + assert ( + await classifier.classify(SimpleNamespace(message_str="帮我搜索一下附近餐厅")) + is TaskType.CONVERSATION + ) + # Word boundaries still hold for the keywords that remain: a keyword + # never fires inside another word. + assert ( + await classifier.classify( + SimpleNamespace(message_str="search refactor helper in the codebase") + ) + is TaskType.WORK + ) + + +@pytest.mark.asyncio +async def test_task_classifier_respects_disabled_work_loop(): + classifier = TaskClassifier( + {"btw": {"enabled": True, "work_loop": {"enabled": False}}} + ) + + assert ( + await classifier.classify(SimpleNamespace(message_str="帮我修改代码")) + is TaskType.CONVERSATION + ) + + +@pytest.mark.asyncio +async def test_task_classifier_accepts_manual_work_command_when_auto_classification_is_disabled(): + classifier = TaskClassifier( + { + "btw": { + "enabled": True, + "classifier": {"enabled": False}, + "work_loop": {"enabled": True}, + } + } + ) + + assert ( + await classifier.classify(SimpleNamespace(message_str="/work 重构项目")) + is TaskType.WORK + ) + + +@pytest.mark.asyncio +async def test_work_loop_marks_failures_without_exposing_executor_error(): + sessions = WorkSessionManager() + work_loop = WorkLoop(FailingExecutor(), sessions) + event = FakeEvent("执行命令") + + with pytest.raises(RuntimeError, match="provider token leaked"): + _ = [item async for item in work_loop.process(event)] + + session = await sessions.get_for_origin(event.unified_msg_origin) + assert session is not None + assert session.status is WorkSessionStatus.FAILED + + +@pytest.mark.asyncio +async def test_work_session_manager_expires_terminal_sessions(): + sessions = WorkSessionManager(max_age_seconds=60) + session = await sessions.create("umo-1", "执行命令") + await sessions.update_status(session.id, WorkSessionStatus.COMPLETED) + session.updated_at = datetime.now(UTC) - timedelta(seconds=61) + + assert await sessions.get_by_id(session.id) is None + assert await sessions.get_for_origin(session.origin) is None + + +@pytest.mark.asyncio +async def test_work_loop_acknowledges_then_runs_in_background(): + executor = BlockingExecutor() + sessions = WorkSessionManager() + work_loop = WorkLoop(executor, sessions) + background_tasks: set[asyncio.Task] = set() + result_dispatcher = AsyncMock() + event_finalizer = AsyncMock() + work_loop.configure_detached_execution( + background_tasks=background_tasks, + result_dispatcher=result_dispatcher, + event_finalizer=event_finalizer, + ) + event = FakeEvent("执行命令") + + output = [item async for item in work_loop.submit(event)] + + assert output == [None] + assert event.result.get_plain_text() == "🔧 工作任务已开始处理。" + assert len(background_tasks) == 1 + [task] = background_tasks + await asyncio.wait_for(executor.started.wait(), timeout=1) + session = await sessions.get_for_origin(event.unified_msg_origin) + assert session is not None + assert session.status is WorkSessionStatus.RUNNING + + executor.release.set() + await asyncio.wait_for(task, timeout=5) + + assert session.status is WorkSessionStatus.COMPLETED + result_dispatcher.assert_awaited_once_with(event) + event_finalizer.assert_awaited_once_with(event) diff --git a/tests/unit/test_builtin_command_extensions.py b/tests/unit/test_builtin_command_extensions.py index d7bf296e66..99195f43e6 100644 --- a/tests/unit/test_builtin_command_extensions.py +++ b/tests/unit/test_builtin_command_extensions.py @@ -1120,3 +1120,31 @@ async def set_provider(**kwargs): await command.set_model(switch_event, "2") assert provider.model == "model-b" assert "Switched model." in _plain_text(switch_event.result) + + +@pytest.mark.asyncio +async def test_work_status_reports_none_and_latest(monkeypatch): + from astrbot.builtin_stars.builtin_commands.commands.work import WorkCommands + from astrbot.core.agent.btw import ( + WorkSessionManager, + WorkSessionStatus, + runtime_registry, + ) + + context = SimpleNamespace(i18n=FakeI18n()) + command = WorkCommands(context) + + monkeypatch.setattr(runtime_registry, "_managers", {}) + event = DummyEvent(message_str="work status") + await command.status(event) + assert _plain_text(event.result) == "No BTW work task has run in this session." + + sessions = WorkSessionManager() + session = await sessions.create("napcat:FriendMessage:42", "refactor the module") + await sessions.update_status(session.id, WorkSessionStatus.RUNNING) + monkeypatch.setattr(runtime_registry, "_managers", {"": sessions}) + latest_event = DummyEvent(message_str="work status") + await command.status(latest_event) + text = _plain_text(latest_event.result) + assert "Running" in text and "refactor the module" in text + assert latest_event.result.is_stopped() diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 686736ed34..4f5e4207e4 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -77,6 +77,40 @@ def test_default_config_avoids_public_listener_addresses(): assert "0.0.0.0" not in values +def test_btw_capability_route_assignments_survive_config_integrity(temp_config_path): + default_config = { + "btw": {"plugin_routes": [], "mcp_routes": [], "skill_routes": []} + } + with open(temp_config_path, "w", encoding="utf-8-sig") as file: + json.dump( + { + "btw": { + "plugin_routes": [ + {"plugin_id": "example", "loop": "both"}, + ], + "mcp_routes": [ + {"server_name": "workspace", "loop": "work"}, + ], + "skill_routes": [ + {"skill_name": "workspace-edit", "loop": "work"}, + ], + } + }, + file, + ) + + config = AstrBotConfig( + config_path=temp_config_path, + default_config=default_config, + ) + + assert config["btw"]["plugin_routes"] == [{"plugin_id": "example", "loop": "both"}] + assert config["btw"]["mcp_routes"] == [{"server_name": "workspace", "loop": "work"}] + assert config["btw"]["skill_routes"] == [ + {"skill_name": "workspace-edit", "loop": "work"} + ] + + def test_default_config_omits_group_active_reply(): assert "active_reply" not in DEFAULT_CONFIG["provider_ltm_settings"] diff --git a/tests/unit/test_conversation_loop.py b/tests/unit/test_conversation_loop.py new file mode 100644 index 0000000000..df975eaeff --- /dev/null +++ b/tests/unit/test_conversation_loop.py @@ -0,0 +1,125 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from astrbot.core.agent.btw import WorkSessionManager, WorkSessionStatus +from astrbot.core.agent.conversation_loop import ConversationLoop + + +class FakeAgentRequest: + def __init__(self) -> None: + self.initialize = AsyncMock() + self.process_calls = [] + + async def process(self, event): + self.process_calls.append(event) + yield "first" + yield "second" + + +class FakeEvent: + def __init__(self, message: str = "hello") -> None: + self.unified_msg_origin = "umo-1" + self.message_str = message + self.extras = {} + self.result = None + + def set_extra(self, key, value) -> None: + self.extras[key] = value + + def get_extra(self, key): + return self.extras.get(key) + + def set_result(self, value) -> None: + self.result = value + + +def _ctx() -> SimpleNamespace: + return SimpleNamespace( + astrbot_config={ + "btw": { + "enabled": True, + "classifier": {"enabled": True}, + "work_loop": {"enabled": True, "max_concurrent": 2}, + } + } + ) + + +@pytest.mark.asyncio +async def test_conversation_loop_initializes_the_current_agent_request_path(): + loop = ConversationLoop(FakeAgentRequest()) + ctx = _ctx() + + await loop.initialize(ctx) + + loop.agent_request.initialize.assert_awaited_once_with(ctx) + assert loop.work_loop is not None + + +@pytest.mark.asyncio +async def test_conversation_loop_forwards_simple_chat_to_agent_request(): + loop = ConversationLoop(FakeAgentRequest()) + await loop.initialize(_ctx()) + event = FakeEvent() + + output = [item async for item in loop.process(event)] + + assert output == ["first", "second"] + assert loop.agent_request.process_calls == [event] + assert event.get_extra("btw_loop") == "conversation" + + +@pytest.mark.asyncio +async def test_conversation_loop_runs_classified_work_and_completes_session(): + loop = ConversationLoop(FakeAgentRequest()) + await loop.initialize(_ctx()) + event = FakeEvent("请帮我重构这个项目") + + output = [item async for item in loop.process(event)] + + assert output == ["first", "second"] + assert event.get_extra("btw_loop") == "work" + session = await loop.work_sessions.get_for_origin(event.unified_msg_origin) + assert session is not None + assert session.status is WorkSessionStatus.COMPLETED + + +@pytest.mark.asyncio +async def test_conversation_loop_exposes_status_via_registry_command(): + """Status queries go through the /work command, not message substrings.""" + sessions = WorkSessionManager() + session = await sessions.create("umo-1", "重构项目") + await sessions.update_status(session.id, WorkSessionStatus.RUNNING) + agent_request = FakeAgentRequest() + loop = ConversationLoop(agent_request, work_sessions=sessions) + await loop.initialize(_ctx()) + loop.expose_to_commands("default") + + from astrbot.core.agent.btw import runtime_registry + + latest = await runtime_registry.latest_status("default", "umo-1") + + assert latest is not None + request, status = latest + assert request == "重构项目" + assert status is WorkSessionStatus.RUNNING + + # Status messages must reach the agent path, not be short-circuited. + event = FakeEvent("进度怎么样了?") + output = [item async for item in loop.process(event)] + assert agent_request.process_calls == [event] + assert output + + +@pytest.mark.asyncio +async def test_conversation_loop_registry_returns_none_without_sessions(): + agent_request = FakeAgentRequest() + loop = ConversationLoop(agent_request, work_sessions=WorkSessionManager()) + await loop.initialize(_ctx()) + loop.expose_to_commands("default") + + from astrbot.core.agent.btw import runtime_registry + + assert await runtime_registry.latest_status("default", "umo-unknown") is None diff --git a/tests/unit/test_process_stage.py b/tests/unit/test_process_stage.py index c84add9edf..c711136272 100644 --- a/tests/unit/test_process_stage.py +++ b/tests/unit/test_process_stage.py @@ -148,7 +148,11 @@ def _stage( astrbot_config={"provider_settings": {"enable": provider_enabled}} ) stage.star_request_sub_stage = FakeSubStage(star_responses or []) - stage.agent_sub_stage = FakeSubStage(agent_responses or []) + agent_request = FakeSubStage(agent_responses or []) + stage._agent_request = agent_request + stage.conversation_loop = None + # Keep the alias while these tests describe the previous request-path name. + stage.agent_sub_stage = agent_request return stage From 583c62806dbc0b01b5bf95936602dc3c1acb4180 Mon Sep 17 00:00:00 2001 From: YUZHEthefool <2804776511@qq.com> Date: Mon, 7 Sep 2026 01:54:21 +0800 Subject: [PATCH 2/5] chore(ci): pin pyupgrade hook to --py313-plus pyupgrade --py314-plus removes `from __future__ import annotations` (PEP 563 is treated as redundant at min_version 3.14), but the runtime still evaluates protocol stub annotations eagerly (tests/unit/test_protocol_function_coverage.py via get_type_hints), so the import must stay. --py313-plus upgrades all 3.13-and-below syntax without removing the import. Update AGENTS.md to match. --- .pre-commit-config.yaml | 8 +++++++- AGENTS.md | 5 ++++- astrbot/builtin_stars/builtin_commands/commands/work.py | 1 - astrbot/core/agent/btw/i18n.py | 1 - astrbot/core/agent/btw/loop_routes.py | 1 - astrbot/core/agent/btw/runtime_registry.py | 1 - astrbot/core/agent/btw/types.py | 1 - astrbot/core/agent/btw/work_sessions.py | 1 - 8 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 70fad7d29b..faa23792b4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,4 +22,10 @@ repos: rev: v3.21.2 hooks: - id: pyupgrade - args: [--py314-plus] + # --py314-plus would strip `from __future__ import annotations` + # (pyupgrade removes it when min_version >= 3.14), but the runtime + # still evaluates protocol stub annotations eagerly + # (tests/unit/test_protocol_function_coverage.py via get_type_hints), + # so the import must stay. --py313-plus upgrades everything up to + # 3.13 syntax without treating PEP 563 as redundant. + args: [--py313-plus] diff --git a/AGENTS.md b/AGENTS.md index 6f0c7981d9..f3aa23d34d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,7 +194,10 @@ endings. Targets such as `format-py`, `format-web`, and `format-md` are scoped only by file type, not by the files changed for the current task. In a dirty worktree, run Ruff or Prettier directly on the intended paths when unrelated same-type edits must be preserved. Ruff uses line length 88, target `py314`, -and mccabe complexity 15. Pre-commit runs Ruff and `pyupgrade --py314-plus`. +and mccabe complexity 15. Pre-commit runs Ruff and +`pyupgrade --py313-plus` (not `--py314-plus`, which strips +`from __future__ import annotations` that the runtime still needs for +eagerly-evaluated protocol stub annotations). ## Architecture diff --git a/astrbot/builtin_stars/builtin_commands/commands/work.py b/astrbot/builtin_stars/builtin_commands/commands/work.py index 545e86cd5c..9d7b952689 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/work.py +++ b/astrbot/builtin_stars/builtin_commands/commands/work.py @@ -1,6 +1,5 @@ """BTW work-loop commands (/work status, /work ).""" - from typing import Annotated from astrbot.api import btw_work_latest_status diff --git a/astrbot/core/agent/btw/i18n.py b/astrbot/core/agent/btw/i18n.py index f3ce162d17..33d5239da1 100644 --- a/astrbot/core/agent/btw/i18n.py +++ b/astrbot/core/agent/btw/i18n.py @@ -6,7 +6,6 @@ to ``zh-CN``. """ - LOCALES: dict[str, dict[str, str]] = { "zh-CN": { "btw.work.started": "🔧 工作任务已开始处理。", diff --git a/astrbot/core/agent/btw/loop_routes.py b/astrbot/core/agent/btw/loop_routes.py index d5061f798e..0e34e25345 100644 --- a/astrbot/core/agent/btw/loop_routes.py +++ b/astrbot/core/agent/btw/loop_routes.py @@ -5,7 +5,6 @@ agent assembly and the handoff tool executor. """ - _LOOP_VALUES = {"conversation", "work"} _ALLOWED_ROUTES = _LOOP_VALUES | {"both"} diff --git a/astrbot/core/agent/btw/runtime_registry.py b/astrbot/core/agent/btw/runtime_registry.py index 60808fbdd8..4a6a7bbf0e 100644 --- a/astrbot/core/agent/btw/runtime_registry.py +++ b/astrbot/core/agent/btw/runtime_registry.py @@ -7,7 +7,6 @@ through this registry. """ - import asyncio from astrbot.core.agent.btw.types import WorkSessionStatus diff --git a/astrbot/core/agent/btw/types.py b/astrbot/core/agent/btw/types.py index 3e8be5abf3..c88ca875e3 100644 --- a/astrbot/core/agent/btw/types.py +++ b/astrbot/core/agent/btw/types.py @@ -1,6 +1,5 @@ """Types shared by the BTW conversation and work loops.""" - from dataclasses import dataclass, field from datetime import UTC, datetime from enum import StrEnum diff --git a/astrbot/core/agent/btw/work_sessions.py b/astrbot/core/agent/btw/work_sessions.py index 47ae159578..7c919f9efc 100644 --- a/astrbot/core/agent/btw/work_sessions.py +++ b/astrbot/core/agent/btw/work_sessions.py @@ -1,6 +1,5 @@ """In-memory runtime ownership for BTW work sessions.""" - import asyncio from datetime import UTC, datetime, timedelta From 743055b93c7cbbf84a83622caf32791b88930b41 Mon Sep 17 00:00:00 2001 From: BegoniaHe Date: Sun, 6 Sep 2026 19:33:00 +0200 Subject: [PATCH 3/5] feat(btw): submit free-text tasks with /work Replace the /work status subcommand with a GreedyStr /work command so /work continues into the work loop without the classifier. Keep /work and /work status as status queries. Drop the classifier /work prefix, share is_work_loop_enabled, and document the command-then-agent exception. Command identity is builtin_commands:work. AI-Generated: true Generated-At: 2026-09-06T17:32:35Z --- astrbot/api/__init__.py | 4 + .../.astrbot-plugin/i18n/en-US.json | 4 +- .../.astrbot-plugin/i18n/zh-CN.json | 4 +- .../builtin_commands/commands/work.py | 42 ++--- .../builtin_stars/builtin_commands/main.py | 20 +-- astrbot/core/agent/btw/__init__.py | 3 +- astrbot/core/agent/btw/runtime_registry.py | 4 +- astrbot/core/agent/btw/task_classifier.py | 34 ++-- astrbot/core/agent/conversation_loop.py | 15 +- astrbot/core/pipeline/process_stage/stage.py | 8 +- docs/en/dev/architecture.md | 2 +- docs/en/dev/astrbot-config.md | 4 +- docs/en/use/command.md | 2 + docs/zh/dev/architecture.md | 2 +- docs/zh/dev/astrbot-config.md | 4 +- docs/zh/use/command.md | 2 + tests/unit/test_btw.py | 33 +++- tests/unit/test_builtin_command_extensions.py | 152 +++++++++++++++++- tests/unit/test_conversation_loop.py | 56 +++++++ tests/unit/test_process_stage.py | 18 +++ 20 files changed, 347 insertions(+), 66 deletions(-) diff --git a/astrbot/api/__init__.py b/astrbot/api/__init__.py index 94aecf4db4..9f6780beed 100644 --- a/astrbot/api/__init__.py +++ b/astrbot/api/__init__.py @@ -21,6 +21,10 @@ "astrbot.core.agent.btw.runtime_registry", "manager_for", ), + "btw_work_loop_enabled": ( + "astrbot.core.agent.btw.task_classifier", + "is_work_loop_enabled", + ), "AuthContext": ("astrbot.core.auth", "AuthContext"), "Decision": ("astrbot.core.auth", "Decision"), "Resource": ("astrbot.core.auth", "Resource"), diff --git a/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/en-US.json b/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/en-US.json index 9b14961e37..c4eece63ca 100644 --- a/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/en-US.json +++ b/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/en-US.json @@ -148,13 +148,13 @@ "provider.models.empty_current": "(empty)", "provider.models.hint": "Use /model set to switch models. Model names can be resolved across configured providers.", "provider.models.invalid_index": "Invalid model index.", + "work.disabled": "The BTW work loop is not enabled.", "work.status.none": "No BTW work task has run in this session.", "work.status.body": "{body}", "work.status.pending": "Queued: {task}", "work.status.running": "Running: {task}", "work.status.completed": "Completed: {task}", "work.status.failed": "Failed: {task}", - "work.status.cancelled": "Cancelled: {task}", - "work.run.usage": "Usage: /work " + "work.status.cancelled": "Cancelled: {task}" } } diff --git a/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/zh-CN.json b/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/zh-CN.json index 39c9c0e613..85965b72b2 100644 --- a/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/zh-CN.json +++ b/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/zh-CN.json @@ -148,13 +148,13 @@ "provider.models.empty_current": "(空)", "provider.models.hint": "使用 /model set <名称或序号> 切换模型。模型名也可以解析到其他已配置 Provider。", "provider.models.invalid_index": "模型序号无效。", + "work.disabled": "BTW 工作循环未启用。", "work.status.none": "本会话还没有 BTW 工作任务。", "work.status.body": "{body}", "work.status.pending": "排队中:{task}", "work.status.running": "执行中:{task}", "work.status.completed": "已完成:{task}", "work.status.failed": "已失败:{task}", - "work.status.cancelled": "已取消:{task}", - "work.run.usage": "用法:/work <任务描述>" + "work.status.cancelled": "已取消:{task}" } } diff --git a/astrbot/builtin_stars/builtin_commands/commands/work.py b/astrbot/builtin_stars/builtin_commands/commands/work.py index 9d7b952689..4ea9f7e19a 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/work.py +++ b/astrbot/builtin_stars/builtin_commands/commands/work.py @@ -1,10 +1,7 @@ -"""BTW work-loop commands (/work status, /work ).""" +"""BTW work-loop command (/work, /work status).""" -from typing import Annotated - -from astrbot.api import btw_work_latest_status +from astrbot.api import btw_work_latest_status, btw_work_loop_enabled from astrbot.api.event import AstrMessageEvent -from astrbot.api.event.filter import GreedyStr from .reply import reply_i18n @@ -15,6 +12,20 @@ class WorkCommands: def __init__(self, context) -> None: self.context = context + async def handle(self, event: AstrMessageEvent, task: str = "") -> None: + """Show status, or hand a free-text task to the work loop. + + ``/work`` and a remainder of ``status`` (case-insensitive) query + the newest session task. Any other remainder is a work-loop + request: the handler rewrites ``event.message_str`` and lets + ProcessStage continue into ConversationLoop. + """ + stripped = (task or "").strip() + if stripped == "" or stripped.lower() == "status": + await self.status(event) + return + await self.submit(event, stripped) + async def status(self, event: AstrMessageEvent) -> None: """Show the newest work-session status for this origin.""" config_id = getattr(getattr(event, "resource", None), "config_id", "") or "" @@ -29,23 +40,14 @@ async def status(self, event: AstrMessageEvent) -> None: body = await self.context.i18n.t(event, f"work.status.{status}", task=request) await reply_i18n(self.context, event, "work.status.body", body=body) - async def run( - self, - event: AstrMessageEvent, - task: Annotated[str, GreedyStr], - ) -> None: - """Dispatch the task text through the BTW work loop. - - The command handler tags the in-flight event so the process stage's - Agent request runs with the work-loop policy; the message text is - rewritten to the task body so downstream assembly sees only the task. - """ - task = (task or "").strip() - if not task: - await reply_i18n(self.context, event, "work.run.usage") + async def submit(self, event: AstrMessageEvent, task: str) -> None: + """Mark the event as a BTW work request and continue to the Agent.""" + cfg = self.context.config.get(umo=event.unified_msg_origin) + if not btw_work_loop_enabled(cfg): + await reply_i18n(self.context, event, "work.disabled") return event.message_str = task event.set_extra("should_run_command", False) event.set_extra("should_run_llm", True) + event.set_extra("btw_force_work", True) event.set_extra("btw_loop", "work") - # Do not stop the event: the pipeline continues into the Agent stage. diff --git a/astrbot/builtin_stars/builtin_commands/main.py b/astrbot/builtin_stars/builtin_commands/main.py index 89b6c22548..48f246e65f 100644 --- a/astrbot/builtin_stars/builtin_commands/main.py +++ b/astrbot/builtin_stars/builtin_commands/main.py @@ -56,25 +56,15 @@ async def bot_status(self, event: AstrMessageEvent) -> None: """Show version and session, LLM, and TTS switches""" await self.bot_c.status(event) - @filter.command_group("work") - def work(self) -> None: - """Inspect BTW work-loop tasks for this session""" - - @filter.permission("session.read") - @work.command("status") - async def work_status(self, event: AstrMessageEvent) -> None: - """Show the newest BTW work task and its status""" - await self.work_c.status(event) - @filter.permission("session.read") - @work.command("run") - async def work_run( + @filter.command("work") + async def work( self, event: AstrMessageEvent, - task: Annotated[str, GreedyStr] = "", + task: GreedyStr = GreedyStr(""), ) -> None: - """Run a task through the BTW work loop""" - await self.work_c.run(event, task) + """Submit a BTW work-loop task, or show the latest status""" + await self.work_c.handle(event, task) @filter.permission("session.manage") @bot.command("enable") diff --git a/astrbot/core/agent/btw/__init__.py b/astrbot/core/agent/btw/__init__.py index e70f0b386c..9357e46749 100644 --- a/astrbot/core/agent/btw/__init__.py +++ b/astrbot/core/agent/btw/__init__.py @@ -1,6 +1,6 @@ """BTW conversation and work-loop primitives.""" -from .task_classifier import TaskClassifier +from .task_classifier import TaskClassifier, is_work_loop_enabled from .types import TaskType, WorkSession, WorkSessionStatus from .work_loop import WorkLoop from .work_sessions import WorkSessionManager @@ -12,4 +12,5 @@ "WorkSession", "WorkSessionManager", "WorkSessionStatus", + "is_work_loop_enabled", ] diff --git a/astrbot/core/agent/btw/runtime_registry.py b/astrbot/core/agent/btw/runtime_registry.py index 4a6a7bbf0e..40d0b7d492 100644 --- a/astrbot/core/agent/btw/runtime_registry.py +++ b/astrbot/core/agent/btw/runtime_registry.py @@ -1,9 +1,9 @@ """Per-profile registry exposing BTW work-session state to commands. -The built-in ``work`` command group queries the newest work session for an +The built-in ``/work`` command queries the newest work session for an origin without owning the pipeline. The pipeline's ``ConversationLoop`` registers its work-session manager under the owning profile's ``config_id`` -at initialization; the command group resolves the event's config and reads +at initialization; the command resolves the event's config and reads through this registry. """ diff --git a/astrbot/core/agent/btw/task_classifier.py b/astrbot/core/agent/btw/task_classifier.py index 26aacf4b7a..3daf36e4e5 100644 --- a/astrbot/core/agent/btw/task_classifier.py +++ b/astrbot/core/agent/btw/task_classifier.py @@ -58,6 +58,25 @@ def _keyword_matches(keyword: str, message: str) -> bool: return keyword in message +def is_work_loop_enabled(config: object) -> bool: + """Return whether BTW and the work loop are both enabled. + + Args: + config: A configuration mapping, or any other object. + + Returns: + True only when both ``btw.enabled`` and ``btw.work_loop.enabled`` + are true. + """ + if not isinstance(config, Mapping): + return False + btw = config.get("btw", {}) + if not isinstance(btw, Mapping) or not btw.get("enabled", False): + return False + work_loop = btw.get("work_loop", {}) + return isinstance(work_loop, Mapping) and bool(work_loop.get("enabled", False)) + + class TaskClassifier: """Classify a request without an additional model call. @@ -66,6 +85,8 @@ class TaskClassifier: the conversation-loop entry point. Classification is opt-in on every layer: when ``btw.enabled``, ``btw.classifier.enabled``, or ``btw.work_loop.enabled`` is false, the classifier never assigns work. + Manual ``/work `` submission is a built-in command, not a + classifier rule. """ def __init__(self, config: Mapping[str, object]) -> None: @@ -80,19 +101,12 @@ async def classify(self, event: AstrMessageEvent) -> TaskType: Returns: The selected task type. """ - btw = self.config.get("btw", {}) - if not isinstance(btw, Mapping) or not btw.get("enabled", False): - return TaskType.CONVERSATION - - work_loop = btw.get("work_loop", {}) - if not isinstance(work_loop, Mapping) or not work_loop.get("enabled", False): + if not is_work_loop_enabled(self.config): return TaskType.CONVERSATION message = (event.message_str or "").strip().lower() - if message.startswith("/work"): - return TaskType.WORK - - classifier = btw.get("classifier", {}) + btw = self.config.get("btw", {}) + classifier = btw.get("classifier", {}) if isinstance(btw, Mapping) else {} if not isinstance(classifier, Mapping) or not classifier.get("enabled", False): return TaskType.CONVERSATION keywords = classifier.get("work_keywords", DEFAULT_WORK_KEYWORDS) diff --git a/astrbot/core/agent/conversation_loop.py b/astrbot/core/agent/conversation_loop.py index 574d303348..c56d187e49 100644 --- a/astrbot/core/agent/conversation_loop.py +++ b/astrbot/core/agent/conversation_loop.py @@ -16,6 +16,7 @@ WorkLoop, WorkSessionManager, WorkSessionStatus, + is_work_loop_enabled, runtime_registry, ) from astrbot.core.platform.astr_message_event import AstrMessageEvent @@ -107,7 +108,7 @@ def configure_detached_work( ) def expose_to_commands(self, config_id: str) -> None: - """Publish work-session state for the built-in ``work`` command group.""" + """Publish work-session state for the built-in ``/work`` command.""" runtime_registry.register(config_id, self.work_sessions) async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: @@ -116,8 +117,9 @@ async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: When BTW is disabled the loop is a transparent pass-through: no classification, no loop tagging — the event reaches the Agent request executor exactly as it would on the upstream path. Work-session - status is queried through the ``/work status`` command, not by - inspecting message text. + status is queried through the ``/work`` command, not by + inspecting message text. ``/work `` sets ``btw_force_work`` + so free-text tasks still enter the work loop. Args: event: The message event to process. @@ -132,7 +134,12 @@ async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: if self.classifier is None: raise RuntimeError("ConversationLoop must be initialized before use") - task_type = await self.classifier.classify(event) + if event.get_extra("btw_force_work") and is_work_loop_enabled( + self.classifier.config + ): + task_type = TaskType.WORK + else: + task_type = await self.classifier.classify(event) if task_type is TaskType.WORK: if self.work_loop is None: raise RuntimeError("ConversationLoop must be initialized before use") diff --git a/astrbot/core/pipeline/process_stage/stage.py b/astrbot/core/pipeline/process_stage/stage.py index 729de30f28..eaa1523a22 100644 --- a/astrbot/core/pipeline/process_stage/stage.py +++ b/astrbot/core/pipeline/process_stage/stage.py @@ -95,5 +95,11 @@ async def process( if ( event.get_result() and not event.is_stopped() ) or not event.get_result(): - async for _ in self._agent_request.process(event): + async for _ in self._dispatch_agent(event): yield + + def _dispatch_agent(self, event: AstrMessageEvent) -> AsyncGenerator[None]: + """Run BTW classification when enabled, otherwise the Agent sub-stage.""" + if self.conversation_loop is not None: + return self.conversation_loop.process(event) + return self._agent_request.process(event) diff --git a/docs/en/dev/architecture.md b/docs/en/dev/architecture.md index c56031743f..73d29df6db 100644 --- a/docs/en/dev/architecture.md +++ b/docs/en/dev/architecture.md @@ -166,7 +166,7 @@ The order in `astrbot/core/pipeline/stage_order.py` is: `GroupMessageHistoryStage` persists inbound group messages other than WebChat before any plugin handles the event, for `GetGroupMessageHistoryTool`. Direct messages and WebChat skip this stage. `ProcessStage` runs plugin handlers and the Agent. `ResultDecorateStage` applies prefixes, segmentation, TTS, local text-to-image rendering, quoting, and related transformations. `RespondStage` uses the platform's unified send API. The scheduler supports both ordinary async stages and async-generator onion middleware; preserve stop-propagation and finalization semantics. `SessionStatusCheckStage` stops events when the session is disabled, except for activated `/bot status` and `/bot enable` so the session can be turned back on from chat. -Inbound routing is a single decision in `WakingCheckStage`: command, LLM, passthrough, or drop. It writes `should_run_command`, `should_run_llm`, `route_kind`, and the explicit `wake_reasons` set onto the event. Command matching runs before LLM access; a matched command wins, a bare command group emits help, and an unknown subcommand emits the Orbit diagnostic without falling through to the LLM. LLM access is selected from the event's configuration profile through `llm_access`; `command_prefixes` only frames command headers. The derived `is_wake` attribute is not a pipeline gate. +Inbound routing is a single decision in `WakingCheckStage`: command, LLM, passthrough, or drop. It writes `should_run_command`, `should_run_llm`, `route_kind`, and the explicit `wake_reasons` set onto the event. Command matching runs before LLM access; a matched command normally only runs the command, a bare command group emits help, and an unknown subcommand emits the Orbit diagnostic without falling through to the LLM. Built-in `/work ` is the exception: the handler rewrites `message_str` and sets `should_run_llm` plus `btw_force_work` so `ProcessStage` continues into the work loop after the command returns. That path still requires `btw.enabled` and `btw.work_loop.enabled` on the profile, and its `command_id` is `builtin_commands:work`. LLM access is selected from the event's configuration profile through `llm_access`; `command_prefixes` only frames command headers. The derived `is_wake` attribute is not a pipeline gate. `TurnCoalesceStage` runs after the allow-list and session checks. When enabled, it hands eligible private-message LLM fragments to the lifecycle-owned, bounded `TurnWindowManager` without waiting in the pipeline. The manager merges fragments, pauses on NapCat typing notices, discards a buffered turn when a command arrives, and requeues one signed flush event through rate limiting and the remaining stages. Adapter-supplied flush flags are stripped; only manager-created events can carry `route_kind=turn_flush`. Notice and request events remain passthrough events, so ephemeral `input_status` never becomes an LLM message. diff --git a/docs/en/dev/astrbot-config.md b/docs/en/dev/astrbot-config.md index 6ee03be284..67386f84f0 100644 --- a/docs/en/dev/astrbot-config.md +++ b/docs/en/dev/astrbot-config.md @@ -189,7 +189,7 @@ Local mode operates directly on the AstrBot host and belongs only in a trusted e ## BTW dual-loop prototype -`btw` provides one entry point for the current dual-loop prototype. Every message first enters the conversation loop. With rule-based classification enabled, requests about code, files, commands, search, research, or coding agents such as Claude Code, Codex, OpenCode, and HAPI, plus requests beginning with `/work`, are sent to the work loop. The work loop reuses the established Agent and tool execution path; core does not provide a dedicated Codex, CC, or other coding-agent executor. The source-built Docker image does preinstall the `claude` and `codex` CLIs, but they are callable only through work-loop shell tools or an external plugin. +`btw` provides one entry point for the current dual-loop prototype. Every message first enters the conversation loop. With rule-based classification enabled, requests about code, files, commands, search, research, or coding agents such as Claude Code, Codex, OpenCode, and HAPI are sent to the work loop. `/work ` is a built-in command that submits the remaining free text to the work loop without the classifier; `/work` and `/work status` query the newest task status. The work loop reuses the established Agent and tool execution path; core does not provide a dedicated Codex, CC, or other coding-agent executor. The source-built Docker image does preinstall the `claude` and `codex` CLIs, but they are callable only through work-loop shell tools or an external plugin. - `btw.enabled` is the master switch. When disabled, every request still uses the existing Agent path through the conversation loop. - `btw.classifier.enabled` enables the built-in deterministic rules. When disabled, requests are not automatically sent to the work loop. @@ -207,7 +207,7 @@ The conversation loop forcibly disables local computer, sandbox, browser, and fi Plugin Pipeline/Star handlers and explicit commands such as `/hapi`, `/codexdev`, `/vibe`, and `/oc` retain the plugin's existing priority and are outside LLM tool routing. Moving those commands into detached work sessions requires explicit plugin support or a future command-execution protocol; a work-only plugin tool assignment does not migrate the entire plugin. -The work loop first replies that the task has started, then continues in a runtime-owned background task. Its results replay the result-decoration stage onward, which includes the reply content-safety check, TTS/T2I decoration, and platform delivery; inbound stages (waking, rate limit, inbound content safety) are not re-run. Background work uses a separate session lock, so it does not block later chat in the same session. Work sessions are runtime-only in-memory state; query them with the `/work status` command. The state is not retained after a restart or runtime rebuild. +The work loop first replies that the task has started, then continues in a runtime-owned background task. Its results replay the result-decoration stage onward, which includes the reply content-safety check, TTS/T2I decoration, and platform delivery; inbound stages (waking, rate limit, inbound content safety) are not re-run. Background work uses a separate session lock, so it does not block later chat in the same session. Work sessions are runtime-only in-memory state; query them with `/work` or `/work status`. The state is not retained after a restart or runtime rebuild. The command identity is `builtin_commands:work`. These settings belong to a configuration profile. Check the BTW switches, concurrency, and plugin-tool assignments separately for every profile. diff --git a/docs/en/use/command.md b/docs/en/use/command.md index 59c53dd081..a5f89610c0 100644 --- a/docs/en/use/command.md +++ b/docs/en/use/command.md @@ -82,6 +82,8 @@ The user ID from `/session info` can be granted current-session `session_admin` ### Running Tasks - `/task stop`: Stop running Agent or third-party Agent Runner tasks in the current session without deleting history. +- `/work `: Submit the remaining free text to the BTW work loop. It does not require the `/chat` prefix or the task classifier. Requires `session.read`, with `btw.enabled` and `btw.work_loop.enabled` on the profile. The command identity is `builtin_commands:work`. +- `/work` or `/work status`: Show the newest work-task status for this session. `status` is a status query only when it is the entire remainder (case-insensitive); `/work status refactor` is submitted as a task. Requires `session.read`. Status lives in runtime memory and is cleared on restart. ### Providers and Models diff --git a/docs/zh/dev/architecture.md b/docs/zh/dev/architecture.md index 834fa415c5..e0be0e706e 100644 --- a/docs/zh/dev/architecture.md +++ b/docs/zh/dev/architecture.md @@ -166,7 +166,7 @@ Mixin 通过带类型的 `store_session(self)` 助手获取会话,不直接持 `GroupMessageHistoryStage` 在插件处理前持久化非 WebChat 的入站群消息,供 `GetGroupMessageHistoryTool` 使用;私聊和 WebChat 会跳过。`ProcessStage` 负责插件处理与 Agent 调用;`ResultDecorateStage` 处理前缀、分段、TTS、本地文转图、引用等结果装饰;`RespondStage` 统一调用平台发送接口。流水线同时支持普通异步 stage 和用异步生成器实现的洋葱式前后处理,修改时必须保留停止传播和收尾语义。`SessionStatusCheckStage` 在会话关闭时停止事件,但放行已激活的 `/bot status` 和 `/bot enable`,以便从聊天重新打开会话。 -入站路由在 `WakingCheckStage` 中一次完成:指令、LLM、透传或丢弃。阶段会把 `should_run_command`、`should_run_llm`、`route_kind` 和明确的 `wake_reasons` 集合写入事件。指令匹配优先于 LLM 访问:命中指令时只执行指令,裸指令组输出帮助,未知子指令输出 Orbit 诊断且不会回落到 LLM。LLM 访问从事件所属配置档的 `llm_access` 读取;`command_prefixes` 只负责指令头。派生属性 `is_wake` 不能作为 Pipeline 门禁。 +入站路由在 `WakingCheckStage` 中一次完成:指令、LLM、透传或丢弃。阶段会把 `should_run_command`、`should_run_llm`、`route_kind` 和明确的 `wake_reasons` 集合写入事件。指令匹配优先于 LLM 访问:命中指令时默认只执行指令,裸指令组输出帮助,未知子指令输出 Orbit 诊断且不会回落到 LLM。内置 `/work <任务>` 是例外:handler 会改写 `message_str`,并设置 `should_run_llm` 与 `btw_force_work`,让 `ProcessStage` 在指令返回后继续进入工作循环;该路径仍要求配置档启用 `btw.enabled` 与 `btw.work_loop.enabled`,且 `command_id` 为 `builtin_commands:work`。LLM 访问从事件所属配置档的 `llm_access` 读取;`command_prefixes` 只负责指令头。派生属性 `is_wake` 不能作为 Pipeline 门禁。 `TurnCoalesceStage` 位于白名单和会话检查之后。启用时,它把符合条件的私聊 LLM 消息片段交给生命周期持有的有界 `TurnWindowManager`,不会在流水线中等待。管理器负责合并片段、根据 NapCat 输入状态暂停、收到指令时丢弃未完成回合,并重新排队一个带签名的 flush 事件,让它经过限流及后续阶段。适配器提供的 flush 标志会被清除,只有管理器创建的事件可以携带 `route_kind=turn_flush`。通知和请求保持透传,因此临时的 `input_status` 不会变成 LLM 消息。 diff --git a/docs/zh/dev/astrbot-config.md b/docs/zh/dev/astrbot-config.md index 58afb67be3..0a543fa611 100644 --- a/docs/zh/dev/astrbot-config.md +++ b/docs/zh/dev/astrbot-config.md @@ -190,7 +190,7 @@ API Key 属于敏感配置。不要把真实 `cmd_config.json`、截图、日志 ## BTW 双循环原型 -`btw` 为当前的双循环原型提供统一入口。所有消息先进入对话循环;启用规则分类后,包含代码、文件、命令、搜索、调研或 Claude Code、Codex、OpenCode、HAPI 等 coding-agent 意图的请求,以及以 `/work` 开头的请求,会转入工作循环。工作循环复用现有 Agent 与工具执行链;核心没有内置 Codex、CC 或其他专用执行器。源码构建的 Docker 镜像虽然预装了 `claude` 和 `codex` CLI,但它们只有通过工作循环的 Shell 工具或外部插件才能被调用。 +`btw` 为当前的双循环原型提供统一入口。所有消息先进入对话循环;启用规则分类后,包含代码、文件、命令、搜索、调研或 Claude Code、Codex、OpenCode、HAPI 等 coding-agent 意图的请求会转入工作循环。`/work <任务>` 是内置指令,不依赖分类器,会把后面的自由文本提交给工作循环;`/work` 与 `/work status` 查询最近一次任务状态。工作循环复用现有 Agent 与工具执行链;核心没有内置 Codex、CC 或其他专用执行器。源码构建的 Docker 镜像虽然预装了 `claude` 和 `codex` CLI,但它们只有通过工作循环的 Shell 工具或外部插件才能被调用。 - `btw.enabled`:总开关。关闭后,所有请求仍通过对话循环使用既有 Agent 路径。 - `btw.classifier.enabled`:启用内置的确定性分类规则;关闭后不会自动转入工作循环。 @@ -208,7 +208,7 @@ API Key 属于敏感配置。不要把真实 `cmd_config.json`、截图、日志 插件的 Pipeline/Star 处理器和 `/hapi`、`/codexdev`、`/vibe`、`/oc` 等显式命令仍按插件既有优先级运行,不属于 LLM 工具路由。要让这类插件命令也采用后台工作会话,需要插件侧或后续的命令执行协议显式支持;不要把“插件工具仅工作循环”理解为整个插件都被迁移。 -工作循环会先回复“工作任务已开始处理”,再由运行时后台任务执行;其结果从结果装饰阶段开始重放,包含回复内容安全检查、TTS/T2I 装饰和平台发送;入站阶段(唤醒、限流、入站内容安全)不会重新执行。后台工作使用与普通对话不同的会话锁,因此不会阻塞同一会话后续的聊天。工作会话是运行时内存状态,通过 `/work status` 指令查询最近一次任务状态;重启或重建运行时后该状态不会保留。 +工作循环会先回复“工作任务已开始处理”,再由运行时后台任务执行;其结果从结果装饰阶段开始重放,包含回复内容安全检查、TTS/T2I 装饰和平台发送;入站阶段(唤醒、限流、入站内容安全)不会重新执行。后台工作使用与普通对话不同的会话锁,因此不会阻塞同一会话后续的聊天。工作会话是运行时内存状态,通过 `/work` 或 `/work status` 查询最近一次任务状态;重启或重建运行时后该状态不会保留。指令身份是 `builtin_commands:work`。 这些设置属于配置档。多个配置档时,应分别检查其 BTW 开关、并发数和插件工具分配。 diff --git a/docs/zh/use/command.md b/docs/zh/use/command.md index 5f04dd46b2..dc95e2141e 100644 --- a/docs/zh/use/command.md +++ b/docs/zh/use/command.md @@ -82,6 +82,8 @@ Orbit 不执行变量、命令、算术或波浪号展开,也不执行 glob、 ### 运行任务 - `/task stop`:停止当前会话中正在运行的 Agent 或第三方 Agent Runner 任务,不删除历史。 +- `/work <任务>`:把后面的自由文本提交给 BTW 工作循环。不依赖 `/chat` 前缀,也不依赖任务分类器。需要 `session.read`,且配置档已启用 `btw.enabled` 与 `btw.work_loop.enabled`。指令身份是 `builtin_commands:work`。 +- `/work` 或 `/work status`:查询本会话最近一次工作任务状态。`status` 只在剩余文本整段匹配时视为查询(大小写不敏感);`/work status 重构` 会作为任务提交。需要 `session.read`。状态只存在于运行时内存,重启后清空。 ### Provider 与模型 diff --git a/tests/unit/test_btw.py b/tests/unit/test_btw.py index 2e94c7915c..e357e37bbb 100644 --- a/tests/unit/test_btw.py +++ b/tests/unit/test_btw.py @@ -11,6 +11,7 @@ WorkLoop, WorkSessionManager, WorkSessionStatus, + is_work_loop_enabled, ) @@ -147,8 +148,32 @@ async def test_task_classifier_respects_disabled_work_loop(): ) +def test_is_work_loop_enabled_requires_both_switches(): + assert is_work_loop_enabled(None) is False + assert is_work_loop_enabled("btw") is False + assert is_work_loop_enabled({}) is False + assert is_work_loop_enabled({"btw": {"enabled": True}}) is False + assert is_work_loop_enabled({"btw": {"enabled": True, "work_loop": True}}) is False + assert ( + is_work_loop_enabled( + {"btw": {"enabled": True, "work_loop": {"enabled": False}}} + ) + is False + ) + assert ( + is_work_loop_enabled( + {"btw": {"enabled": False, "work_loop": {"enabled": True}}} + ) + is False + ) + assert ( + is_work_loop_enabled({"btw": {"enabled": True, "work_loop": {"enabled": True}}}) + is True + ) + + @pytest.mark.asyncio -async def test_task_classifier_accepts_manual_work_command_when_auto_classification_is_disabled(): +async def test_task_classifier_does_not_treat_work_command_text_as_work(): classifier = TaskClassifier( { "btw": { @@ -161,7 +186,11 @@ async def test_task_classifier_accepts_manual_work_command_when_auto_classificat assert ( await classifier.classify(SimpleNamespace(message_str="/work 重构项目")) - is TaskType.WORK + is TaskType.CONVERSATION + ) + assert ( + await classifier.classify(SimpleNamespace(message_str="work 重构项目")) + is TaskType.CONVERSATION ) diff --git a/tests/unit/test_builtin_command_extensions.py b/tests/unit/test_builtin_command_extensions.py index 99195f43e6..380f2560b6 100644 --- a/tests/unit/test_builtin_command_extensions.py +++ b/tests/unit/test_builtin_command_extensions.py @@ -20,6 +20,7 @@ CommandEngine, CommandError, CommandErrorCode, + CommandResolutionKind, build_command_catalog, ) from astrbot.core.command.schema import compile_command_schema @@ -162,6 +163,7 @@ def test_all_builtin_extension_commands_use_native_command_schemas(): "provider_set_stt", "provider_set_tts", "task_stop", + "work", "variable_set", "variable_unset", "flow_enable", @@ -983,6 +985,7 @@ def test_non_public_builtin_commands_declare_the_planned_actions(): "bot_disable": "session.manage", "bot_leave": "session.manage", "task_stop": "session.manage", + "work": "session.read", "conversation_create": "session.manage", "conversation_stats": "session.read", "conversation_history": "session.read", @@ -1032,6 +1035,20 @@ def test_normalized_builtin_paths_resolve_and_legacy_subcommands_do_not(): engine.resolve("flow on") assert flow_legacy.value.diagnostic.code is CommandErrorCode.UNKNOWN_SUBCOMMAND + work_task = engine.resolve("work 帮我重构这个文件") + assert work_task.resolution.kind is CommandResolutionKind.MATCHED + assert work_task.resolution.command_path == ("work",) + work_entry = work_task.resolution.entries[0] + assert dict(engine.bind(work_entry, work_task).values) == { + "task": "帮我重构这个文件" + } + + work_status = engine.resolve("work status") + assert work_status.resolution.kind is CommandResolutionKind.MATCHED + assert work_status.resolution.command_path == ("work",) + status_entry = work_status.resolution.entries[0] + assert dict(engine.bind(status_entry, work_status).values) == {"task": "status"} + class DummyProvider: def __init__(self) -> None: @@ -1131,7 +1148,14 @@ async def test_work_status_reports_none_and_latest(monkeypatch): runtime_registry, ) - context = SimpleNamespace(i18n=FakeI18n()) + context = SimpleNamespace( + i18n=FakeI18n(), + config=SimpleNamespace( + get=lambda umo=None: { + "btw": {"enabled": True, "work_loop": {"enabled": True}} + } + ), + ) command = WorkCommands(context) monkeypatch.setattr(runtime_registry, "_managers", {}) @@ -1148,3 +1172,129 @@ async def test_work_status_reports_none_and_latest(monkeypatch): text = _plain_text(latest_event.result) assert "Running" in text and "refactor the module" in text assert latest_event.result.is_stopped() + + +@pytest.mark.asyncio +async def test_work_handle_submits_free_text_and_rejects_when_disabled(): + from astrbot.builtin_stars.builtin_commands.commands.work import WorkCommands + + enabled = WorkCommands( + SimpleNamespace( + i18n=FakeI18n(), + config=SimpleNamespace( + get=lambda umo=None: { + "btw": {"enabled": True, "work_loop": {"enabled": True}} + } + ), + ) + ) + event = DummyEvent(message_str="work 帮我重构这个文件") + await enabled.handle(event, "帮我重构这个文件") + assert event.message_str == "帮我重构这个文件" + assert event.get_extra("should_run_llm") is True + assert event.get_extra("btw_force_work") is True + assert event.get_extra("btw_loop") == "work" + assert event.result is None + assert event.is_stopped() is False + + status_event = DummyEvent(message_str="work status") + await enabled.handle(status_event, "status") + assert ( + _plain_text(status_event.result) == "No BTW work task has run in this session." + ) + + upper_status = DummyEvent(message_str="work STATUS") + await enabled.handle(upper_status, "STATUS") + assert ( + _plain_text(upper_status.result) == "No BTW work task has run in this session." + ) + + status_task = DummyEvent(message_str="work status 重构") + await enabled.handle(status_task, "status 重构") + assert status_task.message_str == "status 重构" + assert status_task.get_extra("btw_force_work") is True + assert status_task.result is None + + disabled = WorkCommands( + SimpleNamespace( + i18n=FakeI18n(), + config=SimpleNamespace( + get=lambda umo=None: { + "btw": {"enabled": True, "work_loop": {"enabled": False}} + } + ), + ) + ) + blocked = DummyEvent(message_str="work 写一段 python") + await disabled.handle(blocked, "写一段 python") + assert _plain_text(blocked.result) == "The BTW work loop is not enabled." + assert blocked.get_extra("btw_force_work") is None + + +@pytest.mark.asyncio +async def test_work_handle_rejects_invalid_or_master_disabled_btw_config(): + from astrbot.builtin_stars.builtin_commands.commands.work import WorkCommands + + cases = ( + {"btw": {"enabled": False, "work_loop": {"enabled": True}}}, + {"btw": "yes"}, + None, + ) + for config in cases: + command = WorkCommands( + SimpleNamespace( + i18n=FakeI18n(), + config=SimpleNamespace(get=lambda umo=None, value=config: value), + ) + ) + event = DummyEvent(message_str="work 写一段 python") + await command.handle(event, "写一段 python") + assert _plain_text(event.result) == "The BTW work loop is not enabled." + assert event.get_extra("btw_force_work") is None + + +@pytest.mark.asyncio +async def test_work_submit_continues_into_conversation_work_loop(): + from astrbot.builtin_stars.builtin_commands.commands.work import WorkCommands + from astrbot.core.agent.conversation_loop import ConversationLoop + + class FakeAgentRequest: + def __init__(self) -> None: + self.initialize = AsyncMock() + self.process_calls = [] + + async def process(self, event): + self.process_calls.append(event) + yield "first" + + command = WorkCommands( + SimpleNamespace( + i18n=FakeI18n(), + config=SimpleNamespace( + get=lambda umo=None: { + "btw": {"enabled": True, "work_loop": {"enabled": True}} + } + ), + ) + ) + event = DummyEvent(message_str="work 帮我写一段python代码获取当前系统磁盘占用情况") + await command.handle(event, "帮我写一段python代码获取当前系统磁盘占用情况") + + loop = ConversationLoop(FakeAgentRequest()) + await loop.initialize( + SimpleNamespace( + astrbot_config={ + "btw": { + "enabled": True, + "classifier": {"enabled": False}, + "work_loop": {"enabled": True, "max_concurrent": 2}, + } + } + ) + ) + output = [item async for item in loop.process(event)] + + assert event.message_str == "帮我写一段python代码获取当前系统磁盘占用情况" + assert event.get_extra("btw_force_work") is True + assert event.get_extra("btw_loop") == "work" + assert output == ["first"] diff --git a/tests/unit/test_conversation_loop.py b/tests/unit/test_conversation_loop.py index df975eaeff..3b5af7f24c 100644 --- a/tests/unit/test_conversation_loop.py +++ b/tests/unit/test_conversation_loop.py @@ -71,6 +71,62 @@ async def test_conversation_loop_forwards_simple_chat_to_agent_request(): assert event.get_extra("btw_loop") == "conversation" +@pytest.mark.asyncio +async def test_conversation_loop_honors_forced_work_without_classifier_keywords(): + loop = ConversationLoop(FakeAgentRequest()) + await loop.initialize(_ctx()) + event = FakeEvent("帮我写一段python代码获取当前系统磁盘占用情况") + event.set_extra("btw_force_work", True) + + output = [item async for item in loop.process(event)] + + assert output == ["first", "second"] + assert event.get_extra("btw_loop") == "work" + + +@pytest.mark.asyncio +async def test_conversation_loop_ignores_forced_work_when_btw_disabled(): + loop = ConversationLoop(FakeAgentRequest()) + await loop.initialize( + SimpleNamespace( + astrbot_config={ + "btw": {"enabled": False, "work_loop": {"enabled": True}}, + } + ) + ) + event = FakeEvent("帮我写一段python代码获取当前系统磁盘占用情况") + event.set_extra("btw_force_work", True) + + output = [item async for item in loop.process(event)] + + assert output == ["first", "second"] + assert loop.agent_request.process_calls == [event] + assert event.get_extra("btw_loop") is None + + +@pytest.mark.asyncio +async def test_conversation_loop_ignores_forced_work_when_work_loop_disabled(): + loop = ConversationLoop(FakeAgentRequest()) + await loop.initialize( + SimpleNamespace( + astrbot_config={ + "btw": { + "enabled": True, + "classifier": {"enabled": True}, + "work_loop": {"enabled": False, "max_concurrent": 2}, + } + } + ) + ) + event = FakeEvent("帮我写一段python代码获取当前系统磁盘占用情况") + event.set_extra("btw_force_work", True) + + output = [item async for item in loop.process(event)] + + assert output == ["first", "second"] + assert event.get_extra("btw_loop") == "conversation" + + @pytest.mark.asyncio async def test_conversation_loop_runs_classified_work_and_completes_session(): loop = ConversationLoop(FakeAgentRequest()) diff --git a/tests/unit/test_process_stage.py b/tests/unit/test_process_stage.py index c711136272..8d3d7f1373 100644 --- a/tests/unit/test_process_stage.py +++ b/tests/unit/test_process_stage.py @@ -215,6 +215,24 @@ async def test_process_stage_plain_plugin_response_does_not_trigger_agent(): assert stage.agent_sub_stage.calls == [] +@pytest.mark.asyncio +async def test_process_stage_command_handler_can_continue_to_agent_when_llm_requested(): + stage = _stage(star_responses=[None], agent_responses=["agent-step"]) + event = FakeEvent( + extras={ + "activated_handlers": [SimpleNamespace(name="work")], + "should_run_llm": True, + "btw_force_work": True, + } + ) + + yielded = [item async for item in stage.process(event)] + + assert yielded == [None, None] + assert stage.star_request_sub_stage.calls == [(event,)] + assert stage.agent_sub_stage.calls == [(event,)] + + @pytest.mark.asyncio async def test_process_stage_wake_path_runs_agent_without_plugin_handlers(): stage = _stage(agent_responses=["agent-step"]) From 2ae9a931bc7fb2805b2332592cc1a29fe5865012 Mon Sep 17 00:00:00 2001 From: YUZHEthefool <2804776511@qq.com> Date: Thu, 10 Sep 2026 22:56:09 +0800 Subject: [PATCH 4/5] docs(btw): retain the dual-loop design and split classifier trials Replace the prototype delta with a bilingual design proposal. Describe ten capability slices and three provisional classifier experiments, each with a separate PR and a shared evaluation baseline. Defer product routing until the experiments are compared. Preserve the prototype in commit history for later feature extraction. AI-Generated: true Generated-At: 2026-09-10T14:53:32Z --- .pre-commit-config.yaml | 8 +- AGENTS.md | 5 +- astrbot/api/__init__.py | 12 - .../.astrbot-plugin/i18n/en-US.json | 10 +- .../.astrbot-plugin/i18n/zh-CN.json | 10 +- .../builtin_commands/commands/__init__.py | 2 - .../builtin_commands/commands/work.py | 53 ---- .../builtin_stars/builtin_commands/main.py | 12 - astrbot/core/agent/btw/__init__.py | 16 - astrbot/core/agent/btw/i18n.py | 50 --- astrbot/core/agent/btw/loop_routes.py | 57 ---- astrbot/core/agent/btw/runtime_registry.py | 59 ---- astrbot/core/agent/btw/task_classifier.py | 122 -------- astrbot/core/agent/btw/types.py | 53 ---- astrbot/core/agent/btw/work_loop.py | 174 ----------- astrbot/core/agent/btw/work_sessions.py | 106 ------- astrbot/core/agent/conversation_loop.py | 159 ---------- astrbot/core/astr_agent_tool_exec.py | 101 ------ astrbot/core/astr_main_agent.py | 194 +----------- astrbot/core/auth/service.py | 90 +++--- astrbot/core/config/default.py | 102 ------- .../method/agent_sub_stages/internal.py | 125 +------- astrbot/core/pipeline/process_stage/stage.py | 49 +-- astrbot/core/pipeline/scheduler.py | 60 +--- .../mdi-subset/materialdesignicons-subset.css | 6 +- .../materialdesignicons-webfont-subset.woff | Bin 18840 -> 18724 bytes .../materialdesignicons-webfont-subset.woff2 | Bin 15048 -> 14960 bytes .../shared/CapabilityLoopSelector.vue | 188 ------------ .../components/shared/ConfigItemRenderer.vue | 22 -- .../components/shared/PluginLoopSelector.vue | 135 -------- .../en-US/features/config-metadata.json | 60 ---- .../i18n/locales/en-US/features/config.json | 20 -- .../zh-CN/features/config-metadata.json | 60 ---- .../i18n/locales/zh-CN/features/config.json | 20 -- .../tests/capabilityLoopSelector.vitest.ts | 91 ------ dashboard/tests/pluginLoopSelector.vitest.ts | 73 ----- docs/.vitepress/config.mjs | 5 + docs/en/dev/architecture.md | 2 +- docs/en/dev/astrbot-config.md | 29 +- docs/en/dev/btw-dual-loop.md | 105 +++++++ docs/en/use/command.md | 2 - docs/en/use/computer.md | 4 +- docs/zh/dev/architecture.md | 2 +- docs/zh/dev/astrbot-config.md | 30 +- docs/zh/dev/btw-dual-loop.md | 105 +++++++ docs/zh/use/command.md | 2 - docs/zh/use/computer.md | 4 +- tests/unit/test_agent_internal_process.py | 186 +---------- tests/unit/test_astr_agent_tool_exec.py | 114 +------ tests/unit/test_astr_main_agent.py | 289 +----------------- tests/unit/test_authorization_service.py | 62 ---- tests/unit/test_btw.py | 253 --------------- tests/unit/test_builtin_command_extensions.py | 178 ----------- tests/unit/test_config.py | 34 --- tests/unit/test_conversation_loop.py | 181 ----------- tests/unit/test_process_stage.py | 24 +- 56 files changed, 297 insertions(+), 3618 deletions(-) delete mode 100644 astrbot/builtin_stars/builtin_commands/commands/work.py delete mode 100644 astrbot/core/agent/btw/__init__.py delete mode 100644 astrbot/core/agent/btw/i18n.py delete mode 100644 astrbot/core/agent/btw/loop_routes.py delete mode 100644 astrbot/core/agent/btw/runtime_registry.py delete mode 100644 astrbot/core/agent/btw/task_classifier.py delete mode 100644 astrbot/core/agent/btw/types.py delete mode 100644 astrbot/core/agent/btw/work_loop.py delete mode 100644 astrbot/core/agent/btw/work_sessions.py delete mode 100644 astrbot/core/agent/conversation_loop.py delete mode 100644 dashboard/src/components/shared/CapabilityLoopSelector.vue delete mode 100644 dashboard/src/components/shared/PluginLoopSelector.vue delete mode 100644 dashboard/tests/capabilityLoopSelector.vitest.ts delete mode 100644 dashboard/tests/pluginLoopSelector.vitest.ts create mode 100644 docs/en/dev/btw-dual-loop.md create mode 100644 docs/zh/dev/btw-dual-loop.md delete mode 100644 tests/unit/test_btw.py delete mode 100644 tests/unit/test_conversation_loop.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index faa23792b4..70fad7d29b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,10 +22,4 @@ repos: rev: v3.21.2 hooks: - id: pyupgrade - # --py314-plus would strip `from __future__ import annotations` - # (pyupgrade removes it when min_version >= 3.14), but the runtime - # still evaluates protocol stub annotations eagerly - # (tests/unit/test_protocol_function_coverage.py via get_type_hints), - # so the import must stay. --py313-plus upgrades everything up to - # 3.13 syntax without treating PEP 563 as redundant. - args: [--py313-plus] + args: [--py314-plus] diff --git a/AGENTS.md b/AGENTS.md index f3aa23d34d..6f0c7981d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,10 +194,7 @@ endings. Targets such as `format-py`, `format-web`, and `format-md` are scoped only by file type, not by the files changed for the current task. In a dirty worktree, run Ruff or Prettier directly on the intended paths when unrelated same-type edits must be preserved. Ruff uses line length 88, target `py314`, -and mccabe complexity 15. Pre-commit runs Ruff and -`pyupgrade --py313-plus` (not `--py314-plus`, which strips -`from __future__ import annotations` that the runtime still needs for -eagerly-evaluated protocol stub annotations). +and mccabe complexity 15. Pre-commit runs Ruff and `pyupgrade --py314-plus`. ## Architecture diff --git a/astrbot/api/__init__.py b/astrbot/api/__init__.py index 9f6780beed..016633ce03 100644 --- a/astrbot/api/__init__.py +++ b/astrbot/api/__init__.py @@ -13,18 +13,6 @@ from astrbot.core.utils.error_redaction import safe_error _EXPORTS = { - "btw_work_latest_status": ( - "astrbot.core.agent.btw.runtime_registry", - "latest_status", - ), - "btw_work_manager_for": ( - "astrbot.core.agent.btw.runtime_registry", - "manager_for", - ), - "btw_work_loop_enabled": ( - "astrbot.core.agent.btw.task_classifier", - "is_work_loop_enabled", - ), "AuthContext": ("astrbot.core.auth", "AuthContext"), "Decision": ("astrbot.core.auth", "Decision"), "Resource": ("astrbot.core.auth", "Resource"), diff --git a/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/en-US.json b/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/en-US.json index c4eece63ca..49399f73eb 100644 --- a/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/en-US.json +++ b/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/en-US.json @@ -147,14 +147,6 @@ "provider.models.current": "Current model: {model}", "provider.models.empty_current": "(empty)", "provider.models.hint": "Use /model set to switch models. Model names can be resolved across configured providers.", - "provider.models.invalid_index": "Invalid model index.", - "work.disabled": "The BTW work loop is not enabled.", - "work.status.none": "No BTW work task has run in this session.", - "work.status.body": "{body}", - "work.status.pending": "Queued: {task}", - "work.status.running": "Running: {task}", - "work.status.completed": "Completed: {task}", - "work.status.failed": "Failed: {task}", - "work.status.cancelled": "Cancelled: {task}" + "provider.models.invalid_index": "Invalid model index." } } diff --git a/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/zh-CN.json b/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/zh-CN.json index 85965b72b2..e576634804 100644 --- a/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/zh-CN.json +++ b/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/zh-CN.json @@ -147,14 +147,6 @@ "provider.models.current": "当前模型:{model}", "provider.models.empty_current": "(空)", "provider.models.hint": "使用 /model set <名称或序号> 切换模型。模型名也可以解析到其他已配置 Provider。", - "provider.models.invalid_index": "模型序号无效。", - "work.disabled": "BTW 工作循环未启用。", - "work.status.none": "本会话还没有 BTW 工作任务。", - "work.status.body": "{body}", - "work.status.pending": "排队中:{task}", - "work.status.running": "执行中:{task}", - "work.status.completed": "已完成:{task}", - "work.status.failed": "已失败:{task}", - "work.status.cancelled": "已取消:{task}" + "provider.models.invalid_index": "模型序号无效。" } } diff --git a/astrbot/builtin_stars/builtin_commands/commands/__init__.py b/astrbot/builtin_stars/builtin_commands/commands/__init__.py index d46e2deca5..d665dbf916 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/__init__.py +++ b/astrbot/builtin_stars/builtin_commands/commands/__init__.py @@ -11,7 +11,6 @@ from .provider import ProviderCommands from .session import SessionCommands from .variable import VariableCommands -from .work import WorkCommands __all__ = [ "AdminCommands", @@ -25,5 +24,4 @@ "ProviderCommands", "SessionCommands", "VariableCommands", - "WorkCommands", ] diff --git a/astrbot/builtin_stars/builtin_commands/commands/work.py b/astrbot/builtin_stars/builtin_commands/commands/work.py deleted file mode 100644 index 4ea9f7e19a..0000000000 --- a/astrbot/builtin_stars/builtin_commands/commands/work.py +++ /dev/null @@ -1,53 +0,0 @@ -"""BTW work-loop command (/work, /work status).""" - -from astrbot.api import btw_work_latest_status, btw_work_loop_enabled -from astrbot.api.event import AstrMessageEvent - -from .reply import reply_i18n - - -class WorkCommands: - """BTW work-loop command surface.""" - - def __init__(self, context) -> None: - self.context = context - - async def handle(self, event: AstrMessageEvent, task: str = "") -> None: - """Show status, or hand a free-text task to the work loop. - - ``/work`` and a remainder of ``status`` (case-insensitive) query - the newest session task. Any other remainder is a work-loop - request: the handler rewrites ``event.message_str`` and lets - ProcessStage continue into ConversationLoop. - """ - stripped = (task or "").strip() - if stripped == "" or stripped.lower() == "status": - await self.status(event) - return - await self.submit(event, stripped) - - async def status(self, event: AstrMessageEvent) -> None: - """Show the newest work-session status for this origin.""" - config_id = getattr(getattr(event, "resource", None), "config_id", "") or "" - latest = await btw_work_latest_status( - config_id, - event.unified_msg_origin, - ) - if latest is None: - await reply_i18n(self.context, event, "work.status.none") - return - request, status = latest - body = await self.context.i18n.t(event, f"work.status.{status}", task=request) - await reply_i18n(self.context, event, "work.status.body", body=body) - - async def submit(self, event: AstrMessageEvent, task: str) -> None: - """Mark the event as a BTW work request and continue to the Agent.""" - cfg = self.context.config.get(umo=event.unified_msg_origin) - if not btw_work_loop_enabled(cfg): - await reply_i18n(self.context, event, "work.disabled") - return - event.message_str = task - event.set_extra("should_run_command", False) - event.set_extra("should_run_llm", True) - event.set_extra("btw_force_work", True) - event.set_extra("btw_loop", "work") diff --git a/astrbot/builtin_stars/builtin_commands/main.py b/astrbot/builtin_stars/builtin_commands/main.py index 48f246e65f..87e566a10c 100644 --- a/astrbot/builtin_stars/builtin_commands/main.py +++ b/astrbot/builtin_stars/builtin_commands/main.py @@ -16,7 +16,6 @@ ProviderCommands, SessionCommands, VariableCommands, - WorkCommands, ) @@ -35,7 +34,6 @@ def __init__(self, context: star.PluginContext) -> None: self.provider_c = ProviderCommands(self.context) self.session_c = SessionCommands(self.context) self.variable_c = VariableCommands(self.context) - self.work_c = WorkCommands(self.context) @filter.command("help") async def help( @@ -56,16 +54,6 @@ async def bot_status(self, event: AstrMessageEvent) -> None: """Show version and session, LLM, and TTS switches""" await self.bot_c.status(event) - @filter.permission("session.read") - @filter.command("work") - async def work( - self, - event: AstrMessageEvent, - task: GreedyStr = GreedyStr(""), - ) -> None: - """Submit a BTW work-loop task, or show the latest status""" - await self.work_c.handle(event, task) - @filter.permission("session.manage") @bot.command("enable") async def bot_enable(self, event: AstrMessageEvent) -> None: diff --git a/astrbot/core/agent/btw/__init__.py b/astrbot/core/agent/btw/__init__.py deleted file mode 100644 index 9357e46749..0000000000 --- a/astrbot/core/agent/btw/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""BTW conversation and work-loop primitives.""" - -from .task_classifier import TaskClassifier, is_work_loop_enabled -from .types import TaskType, WorkSession, WorkSessionStatus -from .work_loop import WorkLoop -from .work_sessions import WorkSessionManager - -__all__ = [ - "TaskClassifier", - "TaskType", - "WorkLoop", - "WorkSession", - "WorkSessionManager", - "WorkSessionStatus", - "is_work_loop_enabled", -] diff --git a/astrbot/core/agent/btw/i18n.py b/astrbot/core/agent/btw/i18n.py deleted file mode 100644 index 33d5239da1..0000000000 --- a/astrbot/core/agent/btw/i18n.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Locale-aware user-facing strings for the BTW work loop. - -The work loop runs in core without a plugin context, so it resolves the -locale from the event extra/session the same way ``PluginContext._locale`` -does, then looks the string up in these bundles. Missing locales fall back -to ``zh-CN``. -""" - -LOCALES: dict[str, dict[str, str]] = { - "zh-CN": { - "btw.work.started": "🔧 工作任务已开始处理。", - "btw.work.status.pending": "工作任务正在排队。", - "btw.work.status.running": "工作任务正在执行。", - "btw.work.status.completed": "工作任务已完成。", - "btw.work.status.failed": "工作任务执行失败。", - "btw.work.status.cancelled": "工作任务已取消。", - }, - "en-US": { - "btw.work.started": "🔧 Work task started.", - "btw.work.status.pending": "The work task is queued.", - "btw.work.status.running": "The work task is running.", - "btw.work.status.completed": "The work task is completed.", - "btw.work.status.failed": "The work task failed.", - "btw.work.status.cancelled": "The work task was cancelled.", - }, -} - -_FALLBACK_LOCALE = "zh-CN" - - -def resolve_event_locale(event) -> str: - """Return the locale for an event (extra first, then the stored session).""" - getter = getattr(event, "get_extra", None) - if callable(getter): - try: - extra = getter("locale") - except Exception: # noqa: BLE001 - extra = None - if extra: - return str(extra) - return _FALLBACK_LOCALE - - -def text(locale: str, key: str) -> str: - """Return one BTW string for a locale, falling back to zh-CN then key.""" - bundle = LOCALES.get(locale) or LOCALES[_FALLBACK_LOCALE] - value = bundle.get(key) - if value is None: - value = LOCALES[_FALLBACK_LOCALE].get(key, key) - return value diff --git a/astrbot/core/agent/btw/loop_routes.py b/astrbot/core/agent/btw/loop_routes.py deleted file mode 100644 index 0e34e25345..0000000000 --- a/astrbot/core/agent/btw/loop_routes.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Shared BTW loop-route resolution. - -Plugin, MCP, and Skill capability routes follow one matching rule. Keeping a -single narrow implementation here prevents duplicated drift between the main -agent assembly and the handoff tool executor. -""" - -_LOOP_VALUES = {"conversation", "work"} -_ALLOWED_ROUTES = _LOOP_VALUES | {"both"} - - -def route_is_available_in_loop( - routes: object, - *, - route_key: str, - route_id: str, - loop_mode: str, - default_loop: str = "both", -) -> bool: - """Return whether a configured capability is available in one BTW loop. - - Plugin and MCP callers use a work-only default so newly installed execution - capabilities cannot silently enter the conversation loop. Skills keep the - both-loop default because they inject instructions rather than execution - privileges. An explicit route always wins. - - Args: - routes: Saved route assignments (list of ``{route_key, loop}`` dicts, - or a legacy ``{route_id: loop}`` dict). - route_key: Assignment key identifying the capability. - route_id: The capability's identifier. - loop_mode: The loop asking for access (``conversation`` or ``work``). - default_loop: The loop used when no assignment exists. - - Returns: - Whether the capability is available in ``loop_mode``. - """ - if loop_mode not in _LOOP_VALUES or not route_id: - return True - - if default_loop not in _ALLOWED_ROUTES: - default_loop = "work" - route = default_loop - if isinstance(routes, dict): - candidate = routes.get(route_id, default_loop) - route = candidate if isinstance(candidate, str) else default_loop - elif isinstance(routes, list): - for entry in routes: - if not isinstance(entry, dict) or entry.get(route_key) != route_id: - continue - candidate = entry.get("loop", default_loop) - route = candidate if isinstance(candidate, str) else default_loop - break - - if route not in _ALLOWED_ROUTES: - route = default_loop - return route in {"both", loop_mode} diff --git a/astrbot/core/agent/btw/runtime_registry.py b/astrbot/core/agent/btw/runtime_registry.py deleted file mode 100644 index 40d0b7d492..0000000000 --- a/astrbot/core/agent/btw/runtime_registry.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Per-profile registry exposing BTW work-session state to commands. - -The built-in ``/work`` command queries the newest work session for an -origin without owning the pipeline. The pipeline's ``ConversationLoop`` -registers its work-session manager under the owning profile's ``config_id`` -at initialization; the command resolves the event's config and reads -through this registry. -""" - -import asyncio - -from astrbot.core.agent.btw.types import WorkSessionStatus -from astrbot.core.agent.btw.work_sessions import WorkSessionManager - -_lock = asyncio.Lock() -_managers: dict[str, WorkSessionManager] = {} - - -def register(config_id: str, manager: WorkSessionManager) -> None: - """Bind one profile's work-session manager for command queries. - - Args: - config_id: The configuration profile that owns the pipeline. - manager: The profile's work-session manager. - """ - _managers[config_id] = manager - - -def unregister(config_id: str) -> None: - """Drop one profile's registration (pipeline shutdown).""" - _managers.pop(config_id, None) - - -def manager_for(config_id: str) -> WorkSessionManager | None: - """Return the profile's registered work-session manager.""" - return _managers.get(config_id) - - -async def latest_status( - config_id: str, origin: str -) -> tuple[str, WorkSessionStatus] | None: - """Return the newest work session ``(request, status)`` for an origin. - - Args: - config_id: The configuration profile to query. - origin: The unified message origin. - - Returns: - The newest session's request text and status, or ``None`` when the - profile has no live work sessions for the origin. - """ - async with _lock: - manager = _managers.get(config_id) - if manager is None: - return None - session = await manager.get_for_origin(origin) - if session is None: - return None - return (session.request, session.status) diff --git a/astrbot/core/agent/btw/task_classifier.py b/astrbot/core/agent/btw/task_classifier.py deleted file mode 100644 index 3daf36e4e5..0000000000 --- a/astrbot/core/agent/btw/task_classifier.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Rule-based task classification for the BTW prototype.""" - -import re -from collections.abc import Mapping - -from astrbot.core.platform.astr_message_event import AstrMessageEvent - -from .types import TaskType - -DEFAULT_WORK_KEYWORDS = ( - "写代码", - "生成代码", - "修改代码", - "重构", - "创建文件", - "修改文件", - "读取文件", - "执行命令", - "运行命令", - "代码代理", - "编程代理", - "write code", - "generate code", - "refactor", - "create file", - "modify file", - "run command", - "claude code", - "claudecode", - "codex", - "opencode", - "coding agent", - "vibe coding", - "hapi", -) - -# CJK has no whitespace word boundaries, so only latin/digit keywords get -# token-boundary matching; CJK keywords still use substring matching. -_LATIN_KEYWORD_RE_CACHE: dict[str, re.Pattern[str]] = {} - - -def _keyword_matches(keyword: str, message: str) -> bool: - """Match one keyword against the lowercased message. - - Latin/ASCII keywords require a word boundary so that e.g. ``search`` does - not fire inside ``research``. CJK keywords (no whitespace boundaries) - fall back to substring matching. - """ - if re.fullmatch(r"[\W一-鿿]+", keyword, re.ASCII) is None: - # keyword contains at least one ASCII letter/digit: boundary match - pattern = _LATIN_KEYWORD_RE_CACHE.get(keyword) - if pattern is None: - pattern = re.compile( - r"(? bool: - """Return whether BTW and the work loop are both enabled. - - Args: - config: A configuration mapping, or any other object. - - Returns: - True only when both ``btw.enabled`` and ``btw.work_loop.enabled`` - are true. - """ - if not isinstance(config, Mapping): - return False - btw = config.get("btw", {}) - if not isinstance(btw, Mapping) or not btw.get("enabled", False): - return False - work_loop = btw.get("work_loop", {}) - return isinstance(work_loop, Mapping) and bool(work_loop.get("enabled", False)) - - -class TaskClassifier: - """Classify a request without an additional model call. - - The prototype deliberately uses deterministic rules. A future classifier - may replace this implementation behind the same interface without changing - the conversation-loop entry point. Classification is opt-in on every - layer: when ``btw.enabled``, ``btw.classifier.enabled``, or - ``btw.work_loop.enabled`` is false, the classifier never assigns work. - Manual ``/work `` submission is a built-in command, not a - classifier rule. - """ - - def __init__(self, config: Mapping[str, object]) -> None: - self.config = config - - async def classify(self, event: AstrMessageEvent) -> TaskType: - """Return the loop appropriate for an event. - - Args: - event: The incoming message event. - - Returns: - The selected task type. - """ - if not is_work_loop_enabled(self.config): - return TaskType.CONVERSATION - - message = (event.message_str or "").strip().lower() - btw = self.config.get("btw", {}) - classifier = btw.get("classifier", {}) if isinstance(btw, Mapping) else {} - if not isinstance(classifier, Mapping) or not classifier.get("enabled", False): - return TaskType.CONVERSATION - keywords = classifier.get("work_keywords", DEFAULT_WORK_KEYWORDS) - if not isinstance(keywords, list | tuple): - keywords = DEFAULT_WORK_KEYWORDS - if any( - isinstance(keyword, str) - and keyword.strip() - and _keyword_matches(keyword.strip(), message) - for keyword in keywords - ): - return TaskType.WORK - return TaskType.CONVERSATION diff --git a/astrbot/core/agent/btw/types.py b/astrbot/core/agent/btw/types.py deleted file mode 100644 index c88ca875e3..0000000000 --- a/astrbot/core/agent/btw/types.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Types shared by the BTW conversation and work loops.""" - -from dataclasses import dataclass, field -from datetime import UTC, datetime -from enum import StrEnum -from uuid import uuid4 - - -class TaskType(StrEnum): - """The execution loop selected for a user request.""" - - CONVERSATION = "conversation" - WORK = "work" - - -class WorkSessionStatus(StrEnum): - """Lifecycle states for one work-loop request.""" - - PENDING = "pending" - RUNNING = "running" - COMPLETED = "completed" - FAILED = "failed" - CANCELLED = "cancelled" - - -@dataclass(slots=True) -class WorkSession: - """Runtime state shared by the conversation and work loops.""" - - origin: str - request: str - task_type: TaskType = TaskType.WORK - id: str = field(default_factory=lambda: uuid4().hex) - status: WorkSessionStatus = WorkSessionStatus.PENDING - created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) - updated_at: datetime = field(default_factory=lambda: datetime.now(UTC)) - error: str | None = None - - def update_status( - self, - status: WorkSessionStatus, - *, - error: str | None = None, - ) -> None: - """Record a status transition. - - Args: - status: The new work-session status. - error: A safe diagnostic for failed work, when available. - """ - self.status = status - self.error = error - self.updated_at = datetime.now(UTC) diff --git a/astrbot/core/agent/btw/work_loop.py b/astrbot/core/agent/btw/work_loop.py deleted file mode 100644 index b81ba5e2fa..0000000000 --- a/astrbot/core/agent/btw/work_loop.py +++ /dev/null @@ -1,174 +0,0 @@ -"""The BTW work-loop prototype backed by the existing Agent tool loop.""" - -import asyncio -from collections.abc import AsyncGenerator, Awaitable, Callable -from typing import Protocol - -from astrbot.core.message.message_event_result import MessageEventResult -from astrbot.core.platform.astr_message_event import AstrMessageEvent -from astrbot.core.utils.error_redaction import safe_error -from astrbot.core.utils.task_utils import create_tracked_task - -from . import i18n as work_i18n -from .types import WorkSessionStatus -from .work_sessions import WorkSessionManager - - -class AgentRequestExecutor(Protocol): - """The existing Agent request path required by the work loop.""" - - def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: - """Yield pipeline progress markers for one event. - - Protocol stub; concrete implementations are the pipeline's Agent - request sub-stage. The body raises so the statement is effectful - (CodeQL py/ineffectual-statement); the unreachable ``yield`` keeps - the declared ``AsyncGenerator`` return type type-checkable. - """ - raise NotImplementedError - yield # noqa: B901 -- unreachable marker for the type checker - - -ResultDispatcher = Callable[[AstrMessageEvent], Awaitable[None]] -EventFinalizer = Callable[[AstrMessageEvent], Awaitable[None]] - - -class WorkLoop: - """Run classified work with the current Agent and tool infrastructure.""" - - def __init__( - self, - executor: AgentRequestExecutor, - sessions: WorkSessionManager, - *, - max_concurrent: int = 2, - ) -> None: - self.executor = executor - self.sessions = sessions - self._semaphore = asyncio.Semaphore(max(1, max_concurrent)) - self._background_tasks: set[asyncio.Task] | None = None - self._result_dispatcher: ResultDispatcher | None = None - self._event_finalizer: EventFinalizer | None = None - - def configure_detached_execution( - self, - *, - background_tasks: set[asyncio.Task], - result_dispatcher: ResultDispatcher, - event_finalizer: EventFinalizer, - ) -> None: - """Attach runtime-owned background execution services. - - Args: - background_tasks: Runtime task registry cancelled during shutdown. - result_dispatcher: Delivers a generated work result through the - configured result-decorate and response stages. - event_finalizer: Releases the event after detached work finishes. - """ - self._background_tasks = background_tasks - self._result_dispatcher = result_dispatcher - self._event_finalizer = event_finalizer - - async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: - """Execute one work-loop request inline. - - Args: - event: The classified message event. - - Yields: - Pipeline progress markers emitted by the existing Agent executor. - """ - session = await self.sessions.create( - event.unified_msg_origin, event.message_str - ) - self._prepare_event(event, session.id) - async for progress in self._execute(event, session.id): - yield progress - - async def submit(self, event: AstrMessageEvent) -> AsyncGenerator[None]: - """Acknowledge work, then run it without retaining the request pipeline. - - Falls back to inline execution when no runtime task registry is - attached, which keeps the primitive usable in isolated tests. - """ - if ( - self._background_tasks is None - or self._result_dispatcher is None - or self._event_finalizer is None - ): - async for progress in self.process(event): - yield progress - return - - session = await self.sessions.create( - event.unified_msg_origin, event.message_str - ) - self._prepare_event(event, session.id) - event.set_result( - MessageEventResult().message( - work_i18n.text( - work_i18n.resolve_event_locale(event), "btw.work.started" - ) - ) - ) - yield - - # The first yield returns only after the normal response stages deliver - # the acknowledgement. Marking it here prevents the scheduler from - # releasing event-owned temporary files before the worker needs them. - event.set_extra("btw_detached_work", True) - create_tracked_task( - self._background_tasks, - self._run_detached(event, session.id), - name=f"btw_work:{session.id}", - ) - - @staticmethod - def _prepare_event(event: AstrMessageEvent, session_id: str) -> None: - """Mark an event so Agent assembly uses the work-loop policy.""" - event.set_extra("btw_work_session_id", session_id) - event.set_extra("btw_loop", "work") - event.set_extra("btw_agent_lock_key", f"{event.unified_msg_origin}:work") - - async def _execute( - self, - event: AstrMessageEvent, - session_id: str, - ) -> AsyncGenerator[None]: - """Run one already-created work session and update its lifecycle.""" - try: - async with self._semaphore: - await self.sessions.update_status( - session_id, - WorkSessionStatus.RUNNING, - ) - async for progress in self.executor.process(event): - yield progress - except asyncio.CancelledError: - await self.sessions.update_status( - session_id, - WorkSessionStatus.CANCELLED, - ) - raise - except Exception as exc: - await self.sessions.update_status( - session_id, - WorkSessionStatus.FAILED, - error=safe_error("", exc), - ) - raise - else: - await self.sessions.update_status( - session_id, - WorkSessionStatus.COMPLETED, - ) - - async def _run_detached(self, event: AstrMessageEvent, session_id: str) -> None: - """Run work in the runtime task registry and deliver each result.""" - assert self._result_dispatcher is not None - assert self._event_finalizer is not None - try: - async for _ in self._execute(event, session_id): - await self._result_dispatcher(event) - finally: - await self._event_finalizer(event) diff --git a/astrbot/core/agent/btw/work_sessions.py b/astrbot/core/agent/btw/work_sessions.py deleted file mode 100644 index 7c919f9efc..0000000000 --- a/astrbot/core/agent/btw/work_sessions.py +++ /dev/null @@ -1,106 +0,0 @@ -"""In-memory runtime ownership for BTW work sessions.""" - -import asyncio -from datetime import UTC, datetime, timedelta - -from .types import WorkSession, WorkSessionStatus - - -class WorkSessionManager: - """Own active and recent work-loop session state for one pipeline. - - ``get_for_origin`` returns the *latest* session per origin: a new task - replaces the previous session's status-query target, while older - sessions stay addressable by id until they expire. Work-loop - concurrency is bounded by the work loop's semaphore, not here. - """ - - def __init__(self, *, max_age_seconds: int = 3600) -> None: - self._by_origin: dict[str, WorkSession] = {} - self._by_id: dict[str, WorkSession] = {} - self._lock = asyncio.Lock() - self.set_max_age_seconds(max_age_seconds) - - def set_max_age_seconds(self, value: int) -> None: - """Set the retention period for terminal sessions. - - Args: - value: Number of seconds to keep a completed, failed, or cancelled - session before a later manager operation removes it. - """ - self._max_age_seconds = max(1, value) if type(value) is int else 3600 - - async def create(self, origin: str, request: str) -> WorkSession: - """Create and register a work session. - - Args: - origin: The unified message origin that owns the work. - request: The user request being processed. - - Returns: - The newly created work session. - """ - session = WorkSession(origin=origin, request=request) - async with self._lock: - self._cleanup_expired_locked() - self._by_origin[origin] = session - self._by_id[session.id] = session - return session - - async def get_for_origin(self, origin: str) -> WorkSession | None: - """Return the most recent work session for an origin.""" - async with self._lock: - self._cleanup_expired_locked() - return self._by_origin.get(origin) - - async def get_by_id(self, session_id: str) -> WorkSession | None: - """Return a recent work session by its identifier. - - Args: - session_id: The generated work-session identifier. - - Returns: - The matching session, or ``None`` after it has expired. - """ - async with self._lock: - self._cleanup_expired_locked() - return self._by_id.get(session_id) - - async def update_status( - self, - session_id: str, - status: WorkSessionStatus, - *, - error: str | None = None, - ) -> WorkSession | None: - """Transition one known work session. - - Args: - session_id: The work-session identifier. - status: The new lifecycle status. - error: A safe failure message, when applicable. - - Returns: - The updated session, or ``None`` when it has expired. - """ - async with self._lock: - self._cleanup_expired_locked() - session = self._by_id.get(session_id) - if session is not None: - session.update_status(status, error=error) - return session - - def _cleanup_expired_locked(self) -> None: - """Remove old terminal sessions while the manager lock is held.""" - cutoff = datetime.now(UTC) - timedelta(seconds=self._max_age_seconds) - expired_ids = [ - session_id - for session_id, session in self._by_id.items() - if session.status - not in {WorkSessionStatus.PENDING, WorkSessionStatus.RUNNING} - and session.updated_at < cutoff - ] - for session_id in expired_ids: - session = self._by_id.pop(session_id) - if self._by_origin.get(session.origin) is session: - self._by_origin.pop(session.origin, None) diff --git a/astrbot/core/agent/conversation_loop.py b/astrbot/core/agent/conversation_loop.py deleted file mode 100644 index c56d187e49..0000000000 --- a/astrbot/core/agent/conversation_loop.py +++ /dev/null @@ -1,159 +0,0 @@ -"""The user-facing BTW conversation-loop entry point. - -The first BTW increment intentionally reuses the established Agent request -path. It therefore preserves the current local Tool Loop and third-party -Agent runner behaviour, while giving later classifier and work-loop work one -stable hand-off boundary. -""" - -import asyncio -from collections.abc import AsyncGenerator, Awaitable, Callable -from typing import TYPE_CHECKING - -from astrbot.core.agent.btw import ( - TaskClassifier, - TaskType, - WorkLoop, - WorkSessionManager, - WorkSessionStatus, - is_work_loop_enabled, - runtime_registry, -) -from astrbot.core.platform.astr_message_event import AstrMessageEvent - -if TYPE_CHECKING: - from astrbot.core.pipeline.context import PipelineContext - from astrbot.core.pipeline.process_stage.method.agent_request import ( - AgentRequestSubStage, - ) - - -class ConversationLoop: - """Process user-visible AI conversations through the current Agent path. - - It owns task classification and dispatches work requests to the work loop. - Both loops reuse the established Agent request executor. Plugin and MCP - execution capabilities default to the work loop unless an operator assigns - them to the conversation loop or both loops explicitly. When BTW is - disabled the loop is a transparent pass-through to the Agent request - executor, matching the upstream path exactly. - """ - - def __init__( - self, - agent_request: AgentRequestSubStage | None = None, - *, - classifier: TaskClassifier | None = None, - work_sessions: WorkSessionManager | None = None, - ) -> None: - if agent_request is None: - from ..pipeline.process_stage.method.agent_request import ( - AgentRequestSubStage, - ) - - agent_request = AgentRequestSubStage() - self.agent_request = agent_request - self.classifier = classifier - self.work_sessions = work_sessions or WorkSessionManager() - self.work_loop: WorkLoop | None = None - self._btw_enabled = False - - async def initialize(self, ctx: PipelineContext) -> None: - """Initialize the existing Agent request executor. - - Args: - ctx: The owning pipeline context. - """ - await self.agent_request.initialize(ctx) - if self.classifier is None: - self.classifier = TaskClassifier(ctx.astrbot_config) - btw = ctx.astrbot_config.get("btw", {}) - btw = btw if isinstance(btw, dict) else {} - self._btw_enabled = bool(btw.get("enabled", False)) - work_loop_config = btw.get("work_loop", {}) if isinstance(btw, dict) else {} - work_session_config = ( - btw.get("work_session", {}) if isinstance(btw, dict) else {} - ) - max_concurrent = ( - work_loop_config.get("max_concurrent", 2) - if isinstance(work_loop_config, dict) - else 2 - ) - max_age_seconds = ( - work_session_config.get("max_age_seconds", 3600) - if isinstance(work_session_config, dict) - else 3600 - ) - self.work_sessions.set_max_age_seconds(max_age_seconds) - self.work_loop = WorkLoop( - self.agent_request, - self.work_sessions, - max_concurrent=max_concurrent if isinstance(max_concurrent, int) else 2, - ) - - def configure_detached_work( - self, - *, - background_tasks: set[asyncio.Task], - result_dispatcher: Callable[[AstrMessageEvent], Awaitable[None]], - event_finalizer: Callable[[AstrMessageEvent], Awaitable[None]], - ) -> None: - """Attach runtime-owned execution callbacks to the work loop.""" - if self.work_loop is None: - raise RuntimeError("ConversationLoop must be initialized before use") - self.work_loop.configure_detached_execution( - background_tasks=background_tasks, - result_dispatcher=result_dispatcher, - event_finalizer=event_finalizer, - ) - - def expose_to_commands(self, config_id: str) -> None: - """Publish work-session state for the built-in ``/work`` command.""" - runtime_registry.register(config_id, self.work_sessions) - - async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: - """Run one conversation through the current Agent request path. - - When BTW is disabled the loop is a transparent pass-through: no - classification, no loop tagging — the event reaches the Agent request - executor exactly as it would on the upstream path. Work-session - status is queried through the ``/work`` command, not by - inspecting message text. ``/work `` sets ``btw_force_work`` - so free-text tasks still enter the work loop. - - Args: - event: The message event to process. - - Yields: - Pipeline progress markers emitted by the Agent request executor. - """ - if not self._btw_enabled: - async for response in self.agent_request.process(event): - yield response - return - - if self.classifier is None: - raise RuntimeError("ConversationLoop must be initialized before use") - if event.get_extra("btw_force_work") and is_work_loop_enabled( - self.classifier.config - ): - task_type = TaskType.WORK - else: - task_type = await self.classifier.classify(event) - if task_type is TaskType.WORK: - if self.work_loop is None: - raise RuntimeError("ConversationLoop must be initialized before use") - async for response in self.work_loop.submit(event): - yield response - return - - event.set_extra("btw_loop", "conversation") - async for response in self.agent_request.process(event): - yield response - - @staticmethod - def format_status(status: WorkSessionStatus, locale: str = "zh-CN") -> str: - """Render one work-session status as a user-facing string.""" - from astrbot.core.agent.btw import i18n as work_i18n - - return f"📊 {work_i18n.text(locale, f'btw.work.status.{status.value}')}" diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index db7a6da900..590aec4e3d 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -11,7 +11,6 @@ import mcp from astrbot import logger -from astrbot.core.agent.btw.loop_routes import route_is_available_in_loop from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.llm_types import ProviderRequest from astrbot.core.agent.mcp_client import MCPTool @@ -347,87 +346,6 @@ def _get_runtime_computer_tools( } return {} - @staticmethod - def _route_is_available_in_loop( - routes: object, - *, - route_key: str, - route_id: str, - loop_mode: str, - default_loop: str = "both", - ) -> bool: - """Return whether a BTW route permits one nested handoff capability.""" - return route_is_available_in_loop( - routes, - route_key=route_key, - route_id=route_id, - loop_mode=loop_mode, - default_loop=default_loop, - ) - - @classmethod - def _filter_handoff_toolset_for_btw( - cls, - toolset: ToolSet, - *, - ctx, - cfg: dict, - event, - ) -> ToolSet: - """Apply BTW routes to tools exposed inside an existing handoff.""" - btw = cfg.get("btw", {}) - btw = btw if isinstance(btw, dict) else {} - if not btw.get("enabled", False): - # BTW disabled: the Agent path is master-identical, keep the - # handoff toolset as built. - return toolset - get_extra = getattr(event, "get_extra", None) - loop_mode = get_extra("btw_loop") if callable(get_extra) else None - if not isinstance(loop_mode, str) or loop_mode not in { - "conversation", - "work", - }: - return toolset - assert isinstance(loop_mode, str) - - plugins = getattr(getattr(ctx, "catalogs", None), "plugins", None) - filtered = ToolSet() - for tool in toolset.tools: - raw_tool = getattr(tool, "_wrapped", tool) - if loop_mode == "conversation" and type(raw_tool).__module__.startswith( - "astrbot.core.tools.computer_tools" - ): - continue - if isinstance(raw_tool, MCPTool) and not cls._route_is_available_in_loop( - btw.get("mcp_routes", []), - route_key="server_name", - route_id=raw_tool.mcp_server_name, - loop_mode=loop_mode, - default_loop="work", - ): - continue - module_path = getattr(raw_tool, "handler_module_path", None) - plugin = ( - plugins.get_by_module(module_path) - if plugins is not None and module_path - else None - ) - plugin_id = ( - getattr(plugin, "root_dir_name", None) - or getattr(plugin, "name", None) - or "" - ) - if plugin is not None and not cls._route_is_available_in_loop( - btw.get("plugin_routes", []), - route_key="plugin_id", - route_id=plugin_id, - loop_mode=loop_mode, - default_loop="work", - ): - continue - filtered.add_tool(tool) - return filtered - @classmethod def _build_handoff_toolset( cls, @@ -437,15 +355,8 @@ def _build_handoff_toolset( ctx = run_context.context.context event = run_context.context.event cfg = ctx.get_config(umo=event.unified_msg_origin) - btw = cfg.get("btw", {}) - btw = btw if isinstance(btw, dict) else {} - btw_enabled = bool(btw.get("enabled", False)) provider_settings = cfg.get("provider_settings", {}) runtime = str(provider_settings.get("computer_use_runtime", "none")) - get_extra = getattr(event, "get_extra", None) - loop_mode = get_extra("btw_loop") if callable(get_extra) else None - if btw_enabled and loop_mode == "conversation": - runtime = "none" # An explicitly empty handoff tool list needs no registry lookup. In # particular, this keeps the handoff execution path independent from @@ -476,12 +387,6 @@ def _build_handoff_toolset( toolset.add_tool(registered_tool) for runtime_tool in runtime_computer_tools.values(): toolset.add_tool(runtime_tool) - toolset = cls._filter_handoff_toolset_for_btw( - toolset, - ctx=ctx, - cfg=cfg, - event=event, - ) return None if toolset.empty() else toolset toolset = ToolSet() @@ -496,12 +401,6 @@ def _build_handoff_toolset( toolset.add_tool(runtime_tool) elif isinstance(tool_name_or_obj, FunctionTool): toolset.add_tool(tool_name_or_obj) - toolset = cls._filter_handoff_toolset_for_btw( - toolset, - ctx=ctx, - cfg=cfg, - event=event, - ) return None if toolset.empty() else toolset @classmethod diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index b3d2391af0..012d2d0075 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -11,9 +11,6 @@ from typing import Any, TypeGuard, cast from astrbot import logger -from astrbot.core.agent.btw.loop_routes import ( - route_is_available_in_loop as _route_is_available_in_loop, -) from astrbot.core.agent.chat_model import ChatModel from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.llm_types import ProviderRequest @@ -228,15 +225,6 @@ class MainAgentBuildConfig: fallback_provider_ids: list[str] = field(default_factory=list) request_max_retries: int = 5 subagent_orchestrator: dict = field(default_factory=dict) - btw_plugin_routes: object = field(default_factory=list) - btw_mcp_routes: object = field(default_factory=list) - btw_skill_routes: object = field(default_factory=list) - btw_enabled: bool = False - loop_mode: str = "conversation" - provider_id_override: str = "" - conversation_provider_id: str = "" - work_provider_id: str = "" - work_computer_use_runtime: str = "inherit" timezone: str | None = None max_quoted_fallback_images: int = 20 """Maximum number of images injected from quoted-message fallback extraction.""" @@ -365,12 +353,10 @@ def _set_llm_error_message(event: AstrMessageEvent, message: str) -> None: def _select_provider( - event: AstrMessageEvent, - plugin_context: CoreExecutionContext, - provider_id_override: str = "", + event: AstrMessageEvent, plugin_context: CoreExecutionContext ) -> ChatModel | None: """Select chat provider for the event.""" - sel_provider = provider_id_override or event.get_extra("selected_provider") + 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 provider is None: @@ -574,24 +560,6 @@ def _filter_skills_for_current_config( return filtered -def _filter_skills_for_loop( - skills: list[SkillInfo], - routes: object, - loop_mode: str, -) -> list[SkillInfo]: - """Keep only Skills assigned to the current BTW loop.""" - return [ - skill - for skill in skills - if _route_is_available_in_loop( - routes, - route_key="skill_name", - route_id=skill.name, - loop_mode=loop_mode, - ) - ] - - def _get_context_runtime_attr(plugin_context: CoreExecutionContext, name: str): return getattr(plugin_context, "__dict__", {}).get(name) @@ -635,10 +603,6 @@ def _append_skills_prompt( persona: Personality | None, event: AstrMessageEvent, plugin_context: CoreExecutionContext, - *, - loop_mode: str = "conversation", - skill_routes: object = (), - btw_enabled: bool = False, ) -> None: runtime = cfg.get("computer_use_runtime", "none") skill_manager = plugin_context.skill_manager or SkillManager( @@ -649,13 +613,11 @@ def _append_skills_prompt( cfg, plugin_context.catalogs.plugins, ) - if btw_enabled: - skills = _filter_skills_for_loop(skills, skill_routes, loop_mode) workspace_skills = ( skill_manager.list_workspace_skills( _get_workspace_path_for_umo(event.unified_msg_origin) ) - if runtime == "local" and (not btw_enabled or loop_mode == "work") + if runtime == "local" else [] ) if persona and persona.get("skills") is not None: @@ -771,10 +733,6 @@ async def _ensure_persona_and_skills( cfg: dict, plugin_context: CoreExecutionContext, event: AstrMessageEvent, - *, - loop_mode: str = "conversation", - skill_routes: object = (), - btw_enabled: bool = False, ) -> None: """Ensure persona and skills are applied to the request's system prompt or user prompt.""" if not req.conversation: @@ -819,16 +777,7 @@ async def _ensure_persona_and_skills( memory_manager, ) - _append_skills_prompt( - req, - cfg, - persona, - event, - plugin_context, - loop_mode=loop_mode, - skill_routes=skill_routes, - btw_enabled=btw_enabled, - ) + _append_skills_prompt(req, cfg, persona, event, plugin_context) tmgr = plugin_context.get_llm_tool_manager() persona_toolset = _merge_persona_tools(req, persona, tmgr, memory_manager) @@ -1238,15 +1187,7 @@ async def _decorate_llm_request( quote_images_already_captioned = False if req.conversation: - await _ensure_persona_and_skills( - req, - cfg, - plugin_context, - event, - loop_mode=config.loop_mode, - skill_routes=config.btw_skill_routes, - btw_enabled=config.btw_enabled, - ) + await _ensure_persona_and_skills(req, cfg, plugin_context, event) if img_cap_prov_id and req.image_urls and not main_provider_supports_image: await _ensure_img_caption( @@ -1665,120 +1606,6 @@ async def _prepare_request_for_agent( return True -_CONVERSATION_FORBIDDEN_TOOL_TYPES = ( - AnnotateExecutionTool, - BrowserBatchExecTool, - BrowserExecTool, - CreateSkillCandidateTool, - CreateSkillPayloadTool, - CuaKeyboardTypeTool, - CuaMouseClickTool, - CuaScreenshotTool, - EvaluateSkillCandidateTool, - ExecuteShellTool, - FileDownloadTool, - FileEditTool, - FileReadTool, - FileUploadTool, - FileWriteTool, - GetExecutionHistoryTool, - GetSkillPayloadTool, - GrepTool, - ListSkillCandidatesTool, - ListSkillReleasesTool, - LocalPythonTool, - PromoteSkillCandidateTool, - PythonTool, - RollbackSkillReleaseTool, - RunBrowserSkillTool, - ShellSessionTool, - SyncSkillReleaseTool, -) - - -def _filter_privileged_tools_for_conversation( - req: ProviderRequest, - config: MainAgentBuildConfig, -) -> None: - """Ensure the conversation loop cannot call computer or filesystem tools. - - Gated on ``btw_enabled``: with BTW off the Agent path matches upstream - master exactly (no privileged-tool stripping). - """ - if ( - not config.btw_enabled - or config.loop_mode != "conversation" - or req.func_tool is None - ): - return - filtered = ToolSet() - for tool in req.func_tool.tools: - if isinstance(tool, _CONVERSATION_FORBIDDEN_TOOL_TYPES): - continue - filtered.add_tool(tool) - req.func_tool = filtered - - -def _filter_plugin_tools_for_loop( - req: ProviderRequest, - plugin_context: CoreExecutionContext, - config: MainAgentBuildConfig, -) -> None: - """Keep only plugin tools assigned to the current BTW loop. - - Built-in and MCP tools are not owned by a plugin and remain available for - their own policy checks. Omitted or malformed plugin assignments fail - closed to the work loop. When BTW is disabled the Agent path is - master-identical: every plugin tool stays mounted. - """ - if ( - not config.btw_enabled - or req.func_tool is None - or config.loop_mode not in {"conversation", "work"} - ): - return - filtered = ToolSet() - for tool in req.func_tool.tools: - plugin = plugin_context.catalogs.plugins.get_by_module(tool.handler_module_path) - if plugin is None: - filtered.add_tool(tool) - continue - plugin_id = plugin.root_dir_name or plugin.name or "" - if _route_is_available_in_loop( - config.btw_plugin_routes, - route_key="plugin_id", - route_id=plugin_id, - loop_mode=config.loop_mode, - default_loop="work", - ): - filtered.add_tool(tool) - req.func_tool = filtered - - -def _filter_mcp_tools_for_loop( - req: ProviderRequest, config: MainAgentBuildConfig -) -> None: - """Keep only MCP server tools assigned to the current BTW loop.""" - if ( - not config.btw_enabled - or req.func_tool is None - or config.loop_mode not in {"conversation", "work"} - ): - return - - filtered = ToolSet() - for tool in req.func_tool.tools: - if not isinstance(tool, MCPTool) or _route_is_available_in_loop( - config.btw_mcp_routes, - route_key="server_name", - route_id=tool.mcp_server_name, - loop_mode=config.loop_mode, - default_loop="work", - ): - filtered.add_tool(tool) - req.func_tool = filtered - - def _select_request_provider( provider: ChatModel, req: ProviderRequest, @@ -2194,11 +2021,7 @@ async def build_main_agent( If apply_reset is False, will not call reset on the agent runner. """ - provider = provider or _select_provider( - event, - plugin_context, - config.provider_id_override, - ) + provider = provider or _select_provider(event, plugin_context) if provider is None: logger.info("未找到任何对话模型(提供商),跳过 LLM 请求处理。") if not event.get_extra(LLM_ERROR_MESSAGE_EXTRA_KEY): @@ -2244,9 +2067,6 @@ async def build_main_agent( ): return None - _filter_plugin_tools_for_loop(req, plugin_context, config) - _filter_mcp_tools_for_loop(req, config) - if config.add_cron_tools: _proactive_cron_job_tools(req, plugin_context) @@ -2273,8 +2093,6 @@ async def build_main_agent( ) ) - _filter_privileged_tools_for_conversation(req, config) - provider, fallback_providers = _select_request_provider( provider, req, plugin_context, config ) diff --git a/astrbot/core/auth/service.py b/astrbot/core/auth/service.py index b9e5b3794f..9075494c37 100644 --- a/astrbot/core/auth/service.py +++ b/astrbot/core/auth/service.py @@ -1,5 +1,7 @@ """Runtime-owned authorization, audit, and Dashboard step-up service.""" +from __future__ import annotations + import asyncio import hashlib import secrets @@ -1480,69 +1482,49 @@ async def _authorize( ) step_up_id: str | None = None if _requires_step_up(action, resource, context): - if context.source == "dashboard": + if context.source not in {"dashboard", "webchat"}: + return Decision( + False, + subject, + action, + resource, + role, + "high_risk_dashboard_only", + audit_id=audit_id, + matched_relations=tuple(item.relation.value for item in matched), + relation_sources=tuple(item.source for item in matched), + ) + if context.source == "webchat" and ( + subject.kind != "dashboard-account" + or action not in WEBCHAT_INSTANCE_TOOL_ACTIONS + or not context.authenticated + or context.origin_session_resource_id is None + ): + return Decision( + False, + subject, + action, + resource, + role, + "high_risk_dashboard_only", + audit_id=audit_id, + matched_relations=tuple(item.relation.value for item in matched), + relation_sources=tuple(item.source for item in matched), + ) + step_up_id = _webchat_step_up_cached(context, action) + if step_up_id is None: step_up_id = await self._consume_step_up( subject, action, resource, context ) - if step_up_id is None: - return Decision( - False, - subject, - action, - resource, - role, - "step_up_required", - requires_step_up=True, - audit_id=audit_id, - ) - elif context.source == "webchat": - if ( - subject.kind != "dashboard-account" - or action not in WEBCHAT_INSTANCE_TOOL_ACTIONS - or not context.authenticated - or context.origin_session_resource_id is None - ): - return Decision( - False, - subject, - action, - resource, - role, - "high_risk_dashboard_only", - audit_id=audit_id, - matched_relations=tuple( - item.relation.value for item in matched - ), - relation_sources=tuple(item.source for item in matched), - ) - step_up_id = _webchat_step_up_cached(context, action) - if step_up_id is None: - step_up_id = await self._consume_step_up( - subject, action, resource, context - ) - if step_up_id is None: - return Decision( - False, - subject, - action, - resource, - role, - "step_up_required", - requires_step_up=True, - audit_id=audit_id, - matched_relations=tuple( - item.relation.value for item in matched - ), - relation_sources=tuple(item.source for item in matched), - ) - else: + if step_up_id is None: return Decision( False, subject, action, resource, role, - "high_risk_dashboard_only", + "step_up_required", + requires_step_up=True, audit_id=audit_id, matched_relations=tuple(item.relation.value for item in matched), relation_sources=tuple(item.source for item in matched), diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index f0490c8220..9f356529e3 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -188,30 +188,6 @@ ), "agents": [], }, - "btw": { - # Experimental prototype: off by default. Enabling ``enabled`` also - # requires enabling the work loop; high-risk tool actions stay denied - # from IM per the upstream rules (no elevation path). - "enabled": False, - "classifier": { - "enabled": False, - }, - "conversation_loop": { - "provider_id": "", - }, - "work_loop": { - "enabled": False, - "provider_id": "", - "computer_use_runtime": "inherit", - "max_concurrent": 2, - }, - "work_session": { - "max_age_seconds": 3600, - }, - "plugin_routes": [], - "mcp_routes": [], - "skill_routes": [], - }, "provider_stt_settings": { "enable": False, "provider_id": "", @@ -4655,86 +4631,8 @@ }, }, }, - "btw": { - "description": "BTW 双循环", - "type": "object", - "items": { - "btw.enabled": { - "description": "启用 BTW 双循环", - "type": "bool", - "hint": "实验性原型,默认关闭。开启后由对话循环统一接收消息,并将显式工作请求转入工作循环;高风险工具动作仍然按上游规则拒绝,IM 不提权。", - }, - "btw.classifier.enabled": { - "description": "启用任务分类", - "type": "bool", - "hint": "可选的启发式规则,默认关闭。开启后按内置规则把疑似工作请求转入工作循环;/work 指令不依赖此开关。", - "condition": {"btw.enabled": True}, - }, - "btw.conversation_loop.provider_id": { - "description": "对话循环模型", - "type": "string", - "_special": "select_provider", - "hint": "留空时使用当前会话的默认对话模型。对话循环不会获得本地、沙盒或文件工具。", - "condition": {"btw.enabled": True}, - }, - "btw.work_loop.enabled": { - "description": "启用工作循环", - "type": "bool", - "hint": "关闭后,所有请求都由对话循环处理。", - "condition": {"btw.enabled": True}, - }, - "btw.work_loop.provider_id": { - "description": "工作循环模型", - "type": "string", - "_special": "select_provider", - "hint": "留空时使用当前会话的默认对话模型。配置后会优先于会话模型选择。", - "condition": {"btw.work_loop.enabled": True}, - }, - "btw.work_loop.computer_use_runtime": { - "description": "工作循环电脑权限", - "type": "string", - "options": ["inherit", "none", "local", "sandbox"], - "hint": "inherit 使用现有电脑使用配置;local 和 sandbox 只会暴露给工作循环。", - "condition": {"btw.work_loop.enabled": True}, - }, - "btw.work_loop.max_concurrent": { - "description": "工作循环最大并发数", - "type": "int", - "hint": "同一配置文件中可同时执行的工作任务数量。", - "condition": {"btw.work_loop.enabled": True}, - }, - "btw.work_session.max_age_seconds": { - "description": "工作会话保留时长", - "type": "int", - "hint": "已完成、失败或取消的工作任务保留多少秒以供状态查询。", - "condition": {"btw.enabled": True}, - }, - "btw.plugin_routes": { - "description": "插件工具循环分配", - "type": "list", - "hint": "插件 LLM 工具默认仅在工作循环可用;可为每个已启用插件显式改为对话循环或两者。", - "_special": "select_plugin_loop_routes", - "condition": {"btw.enabled": True}, - }, - "btw.mcp_routes": { - "description": "MCP 服务器循环分配", - "type": "list", - "hint": "MCP 工具默认仅在工作循环可用;可为每个已启用服务器显式改为对话循环或两者。", - "_special": "select_mcp_loop_routes", - "condition": {"btw.enabled": True}, - }, - "btw.skill_routes": { - "description": "Skills 循环分配", - "type": "list", - "hint": "Skill 默认注入两个循环;可为每个已启用 Skill 显式限制到单一循环。", - "_special": "select_skill_loop_routes", - "condition": {"btw.enabled": True}, - }, - }, - }, } - CONFIG_METADATA_3_SYSTEM = { "system_group": { "name": "系统配置", 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 71d9ee4493..c692f1f07c 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 @@ -25,7 +25,6 @@ ) from astrbot.core.astr_main_agent import ( LLM_ERROR_MESSAGE_EXTRA_KEY, - MainAgentBuildConfig, MainAgentBuildResult, build_main_agent, local_agent_runtime_from_profile, @@ -105,56 +104,9 @@ async def initialize(self, ctx: PipelineContext) -> None: False, ) self.show_reasoning = settings.get("display_reasoning_text", False) - - btw_config = conf.get("btw", {}) - btw_config = btw_config if isinstance(btw_config, dict) else {} - self.btw_enabled = bool(btw_config.get("enabled", False)) - conversation_loop_config = btw_config.get("conversation_loop", {}) - conversation_loop_config = ( - conversation_loop_config - if isinstance(conversation_loop_config, dict) - else {} - ) - work_loop_config = btw_config.get("work_loop", {}) - work_loop_config = ( - work_loop_config if isinstance(work_loop_config, dict) else {} - ) - self.conversation_provider_id = conversation_loop_config.get("provider_id", "") - if not isinstance(self.conversation_provider_id, str): - self.conversation_provider_id = "" - self.work_provider_id = work_loop_config.get("provider_id", "") - if not isinstance(self.work_provider_id, str): - self.work_provider_id = "" - self.work_computer_use_runtime = work_loop_config.get( - "computer_use_runtime", "inherit" - ) - if self.work_computer_use_runtime not in { - "inherit", - "none", - "local", - "sandbox", - }: - self.work_computer_use_runtime = "inherit" - self.conv_manager = ctx.execution_context.conversation_manager self.main_agent_cfg, self.max_step = local_agent_runtime_from_profile( conf, - btw_plugin_routes=( - btw_config.get("plugin_routes", []) - if isinstance(btw_config, dict) - else [] - ), - btw_mcp_routes=( - btw_config.get("mcp_routes", []) if isinstance(btw_config, dict) else [] - ), - btw_skill_routes=( - btw_config.get("skill_routes", []) - if isinstance(btw_config, dict) - else [] - ), - conversation_provider_id=self.conversation_provider_id, - work_provider_id=self.work_provider_id, - work_computer_use_runtime=self.work_computer_use_runtime, timezone=self.ctx.execution_context.get_config().get("timezone"), ) self.tool_call_timeout = self.main_agent_cfg.tool_call_timeout @@ -315,65 +267,11 @@ async def _build_checked_agent_runner( event: AstrMessageEvent, streaming_response: bool, ) -> MainAgentBuildResult | None: - """Build a runner and reject configured provider endpoints unsafe for use. - - With BTW disabled the runner is built from the profile as-is: no - loop-mode override, no conversation hard-isolation — the path matches - upstream master exactly. - """ - if not getattr(self, "btw_enabled", False): - build_cfg = replace( - self.main_agent_cfg, - streaming_response=streaming_response, - btw_enabled=False, - ) - return await self._run_checked_build(event, build_cfg) - - loop_mode = "work" if event.get_extra("btw_loop") == "work" else "conversation" - if loop_mode == "conversation": - computer_use_runtime = "none" - provider_id_override = getattr( - self.main_agent_cfg, "conversation_provider_id", "" - ) - else: - computer_use_runtime = getattr( - self.main_agent_cfg, "work_computer_use_runtime", "inherit" - ) - if computer_use_runtime == "inherit": - computer_use_runtime = getattr( - self.main_agent_cfg, "computer_use_runtime", None - ) - if computer_use_runtime not in {"none", "local", "sandbox"}: - computer_use_runtime = "none" - provider_id_override = getattr(self.main_agent_cfg, "work_provider_id", "") - - configured_provider_settings = getattr( - self.main_agent_cfg, "provider_settings", {} - ) - provider_settings = ( - dict(configured_provider_settings) - if isinstance(configured_provider_settings, dict) - else {} - ) - provider_settings["computer_use_runtime"] = computer_use_runtime - + """Build a runner and reject configured provider endpoints unsafe for use.""" build_cfg = replace( self.main_agent_cfg, streaming_response=streaming_response, - loop_mode=loop_mode, - provider_id_override=provider_id_override, - computer_use_runtime=computer_use_runtime, - provider_settings=provider_settings, - btw_enabled=True, ) - return await self._run_checked_build(event, build_cfg) - - async def _run_checked_build( - self, - event: AstrMessageEvent, - build_cfg: MainAgentBuildConfig, - ) -> MainAgentBuildResult | None: - """Run the shared build + blocked-host check for one build config.""" build_result = await build_main_agent( event=event, plugin_context=self.ctx.execution_context, @@ -403,10 +301,6 @@ async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: follow_up_activated = False typing_requested = False try: - # BTW work-loop tasks may detach from the originating event so the - # parent task retains the follow-up runner; the streaming choice is - # still resolved via the unified session override helper below. - is_detached_work = bool(event.get_extra("btw_detached_work")) from astrbot.core.streaming_override import resolve_streaming_response streaming_response = await resolve_streaming_response( @@ -462,9 +356,7 @@ async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: logger.debug("ready to request llm provider") follow_up_capture = ( - None - if is_detached_work - else self.ctx.execution_context.follow_up_coordinator.try_capture(event) + self.ctx.execution_context.follow_up_coordinator.try_capture(event) ) if follow_up_capture: ( @@ -501,12 +393,6 @@ async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: concurrent, lock_key, turn_cm, streaming_response = ( self._prepare_group_sender_concurrency(event, streaming_response) ) - # BTW work-loop tasks share one agent lock across related turns; - # honor the loop-provided key when present, else keep the group - # sender lock key resolved above. - btw_lock_key = event.get_extra("btw_agent_lock_key") - if isinstance(btw_lock_key, str) and btw_lock_key: - lock_key = btw_lock_key async with ( turn_cm, @@ -583,11 +469,8 @@ async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None]: ) else: runner_stop_callback = None - # BTW detached work tasks are managed by their parent turn - # and must not register their own follow-up runner. - if not is_detached_work: - self._register_follow_up_runner(event, agent_runner, concurrent) - runner_registered = True + self._register_follow_up_runner(event, agent_runner, concurrent) + runner_registered = True event.trace.record( "astr_agent_prepare", system_prompt=req.system_prompt, diff --git a/astrbot/core/pipeline/process_stage/stage.py b/astrbot/core/pipeline/process_stage/stage.py index eaa1523a22..51e54642be 100644 --- a/astrbot/core/pipeline/process_stage/stage.py +++ b/astrbot/core/pipeline/process_stage/stage.py @@ -1,7 +1,5 @@ -import asyncio -from collections.abc import AsyncGenerator, Awaitable, Callable +from collections.abc import AsyncGenerator -from astrbot.core.agent.conversation_loop import ConversationLoop from astrbot.core.agent.llm_types import ProviderRequest from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.star.star_handler import StarHandlerMetadata @@ -17,43 +15,14 @@ async def initialize(self, ctx: PipelineContext) -> None: self.ctx = ctx self.config = ctx.astrbot_config - btw = self.config.get("btw", {}) - btw = btw if isinstance(btw, dict) else {} - self._btw_enabled = bool(btw.get("enabled", False)) - if self._btw_enabled: - # BTW dual-loop mode: the ConversationLoop classifies and - # dispatches work requests over the same Agent sub-stage. - self.conversation_loop = ConversationLoop() - await self.conversation_loop.initialize(ctx) - self.conversation_loop.expose_to_commands(ctx.astrbot_config_id) - self._agent_request = self.conversation_loop.agent_request - else: - # BTW disabled: ProcessStage holds the current Agent sub-stage - # directly, exactly as upstream master does — no wrapper. - self.conversation_loop = None - self._agent_request = AgentRequestSubStage() - await self._agent_request.initialize(ctx) + # initialize agent sub stage + self.agent_sub_stage = AgentRequestSubStage() + await self.agent_sub_stage.initialize(ctx) # initialize star request sub stage self.star_request_sub_stage = StarRequestSubStage() await self.star_request_sub_stage.initialize(ctx) - def configure_detached_work( - self, - *, - background_tasks: set[asyncio.Task], - result_dispatcher: Callable[[AstrMessageEvent], Awaitable[None]], - event_finalizer: Callable[[AstrMessageEvent], Awaitable[None]], - ) -> None: - """Give the BTW work loop lifecycle-owned background services.""" - if self.conversation_loop is None: - return - self.conversation_loop.configure_detached_work( - background_tasks=background_tasks, - result_dispatcher=result_dispatcher, - event_finalizer=event_finalizer, - ) - async def process( self, event: AstrMessageEvent, @@ -72,7 +41,7 @@ async def process( handled_plugin_provider_request = True event.set_extra("provider_request", resp) _t = False - async for _ in self._agent_request.process(event): + async for _ in self.agent_sub_stage.process(event): _t = True yield if not _t: @@ -95,11 +64,5 @@ async def process( if ( event.get_result() and not event.is_stopped() ) or not event.get_result(): - async for _ in self._dispatch_agent(event): + async for _ in self.agent_sub_stage.process(event): yield - - def _dispatch_agent(self, event: AstrMessageEvent) -> AsyncGenerator[None]: - """Run BTW classification when enabled, otherwise the Agent sub-stage.""" - if self.conversation_loop is not None: - return self.conversation_loop.process(event) - return self._agent_request.process(event) diff --git a/astrbot/core/pipeline/scheduler.py b/astrbot/core/pipeline/scheduler.py index 154489af98..0cf797aca5 100644 --- a/astrbot/core/pipeline/scheduler.py +++ b/astrbot/core/pipeline/scheduler.py @@ -8,22 +8,13 @@ from .bootstrap import builtin_stage_classes from .context import PipelineContext -from .result_decorate.stage import ResultDecorateStage from .stage import Stage class _EmptyCompletionEvent(Protocol): """An adapter event that accepts an empty completion signal.""" - def send(self, message: MessageChain | None) -> Awaitable[object]: - """Protocol stub: only adapters whose send accepts None match. - - The body raises so the statement is effectful (CodeQL - py/ineffectual-statement); the unreachable ``...`` keeps the stub - form for the type checker. - """ - raise NotImplementedError - ... # unreachable stub marker for the type checker + def send(self, message: MessageChain | None) -> Awaitable[object]: ... class PipelineScheduler: @@ -42,48 +33,6 @@ async def initialize(self) -> None: stage_instance = stage_cls() # 创建实例 await stage_instance.initialize(self.ctx) self.stages.append(stage_instance) - for stage in self.stages: - configure_detached_work = getattr(stage, "configure_detached_work", None) - if callable(configure_detached_work): - configure_detached_work( - background_tasks=self.ctx.execution_context.background_tasks, - result_dispatcher=self.deliver_detached_result, - event_finalizer=self.finalize_detached_event, - ) - - async def deliver_detached_result(self, event: AstrMessageEvent) -> None: - """Run the configured decoration and response stages for detached work. - - Replays from the first result-decorate stage onward — the decorate - stage's reply content-safety check, TTS/T2I decoration, and the send - stage. Inbound stages (waking, rate limit, inbound content-safety) - already ran for the originating message and are not re-run. - """ - result_stage_index = next( - ( - index - for index, stage in enumerate(self.stages) - if isinstance(stage, ResultDecorateStage) - ), - None, - ) - if result_stage_index is None: - raise RuntimeError("ResultDecorateStage is not configured") - for stage in self.stages[result_stage_index:]: - coroutine = stage.process(event) - if isinstance(coroutine, AsyncGenerator): - async for _ in coroutine: - pass - else: - _ = await coroutine - if event.is_stopped(): - return - - async def finalize_detached_event(self, event: AstrMessageEvent) -> None: - """Release an event retained while its detached work is running.""" - event.set_extra("btw_detached_work_finished", True) - event.cleanup_temporary_local_files() - self.ctx.execution_context.active_event_registry.unregister(event) async def _process_stages(self, event: AstrMessageEvent, from_stage=0) -> None: """依次执行各个阶段 @@ -163,8 +112,5 @@ async def execute(self, event: AstrMessageEvent) -> None: else: logger.debug("pipeline execution completed.") finally: - if event.get_extra("btw_detached_work", False): - logger.debug("deferred event cleanup until BTW work finishes") - else: - event.cleanup_temporary_local_files() - self.ctx.execution_context.active_event_registry.unregister(event) + event.cleanup_temporary_local_files() + self.ctx.execution_context.active_event_registry.unregister(event) diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index 410a0fd072..c746365c50 100644 --- a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css +++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css @@ -1,4 +1,4 @@ -/* Auto-generated MDI subset – 273 icons */ +/* Auto-generated MDI subset – 272 icons */ /* Do not edit manually. Run: pnpm run subset-icons */ @font-face { @@ -652,10 +652,6 @@ content: "\F16A8"; } -.mdi-lock-off-outline::before { - content: "\F1672"; -} - .mdi-lock-outline::before { content: "\F0341"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index f0fa6ac0083af491098105aa55ecdae040ac4180..059a3ddc92315429fd8651c2d138cb85962a02a0 100644 GIT binary patch delta 17425 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 U0zw9c|6o1?02&Md&yy2Lg@E#0ZU6uP delta 17663 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 diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 index 430e65cb43addfbfd19ddc50fae1ec26ea831232..10dc9ebf3b0686189194ee350392fbb6a66d683a 100644 GIT binary patch literal 14960 zcmV-$I*-M7Pew8T0RR9106K603jhEB0Eol@06HH40RR9100000000000000000000 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 literal 15048 zcmV;(Iyc34Pew8T0RR9106NG33jhEB0Eu`206KO60RR9100000000000000000000 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 -
- - - {{ hint }} - - - - - {{ tm('capabilityLoopSelector.capability') }} - {{ tm('capabilityLoopSelector.loop') }} - - - - - {{ capability.label }} - - - - - - -
- {{ emptyMessage }} -
-
- - - - - diff --git a/dashboard/src/components/shared/ConfigItemRenderer.vue b/dashboard/src/components/shared/ConfigItemRenderer.vue index 26585f0185..a77382a606 100644 --- a/dashboard/src/components/shared/ConfigItemRenderer.vue +++ b/dashboard/src/components/shared/ConfigItemRenderer.vue @@ -63,26 +63,6 @@ @update:model-value="emitUpdate" /> - - - @@ -326,8 +306,6 @@ import ProviderSelector from './ProviderSelector.vue'; import PersonaSelector from './PersonaSelector.vue'; import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'; import PluginSetSelector from './PluginSetSelector.vue'; -import PluginLoopSelector from './PluginLoopSelector.vue'; -import CapabilityLoopSelector from './CapabilityLoopSelector.vue'; import T2ITemplateEditor from './T2ITemplateEditor.vue'; import DashboardTotpManager from './DashboardTotpManager.vue'; import { computed, ref } from 'vue'; diff --git a/dashboard/src/components/shared/PluginLoopSelector.vue b/dashboard/src/components/shared/PluginLoopSelector.vue deleted file mode 100644 index 005f5541d2..0000000000 --- a/dashboard/src/components/shared/PluginLoopSelector.vue +++ /dev/null @@ -1,135 +0,0 @@ - - - - - diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 9c9acaa1ec..2750c788ae 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1174,63 +1174,6 @@ "description": "Available Plugins", "hint": "All non-disabled plugins are enabled by default. If a plugin is disabled on the plugins page, selections here will not take effect." } - }, - "btw": { - "description": "BTW dual loops", - "btw": { - "enabled": { - "description": "Enable BTW dual loops", - "hint": "Experimental prototype, disabled by default. When enabled, the conversation loop receives all messages and explicit work requests go to the work loop; high-risk tool actions remain denied from IM per the upstream rules." - }, - "classifier": { - "enabled": { - "description": "Enable task classification", - "hint": "Optional heuristic rules, disabled by default. When enabled, messages matching built-in rules are routed to the work loop; the /work command does not depend on this switch." - } - }, - "conversation_loop": { - "provider_id": { - "description": "Conversation-loop model", - "hint": "Leave empty to use the current session's default chat model. The conversation loop never receives local, sandbox, or filesystem tools." - } - }, - "work_loop": { - "enabled": { - "description": "Enable work loop", - "hint": "When disabled, the conversation loop handles every request." - }, - "provider_id": { - "description": "Work-loop model", - "hint": "Leave empty to use the current session's default chat model. When set, it takes priority over a session model selection." - }, - "computer_use_runtime": { - "description": "Work-loop computer permission", - "hint": "inherit uses the existing computer-use setting; local and sandbox are exposed only to the work loop." - }, - "max_concurrent": { - "description": "Maximum concurrent work loops", - "hint": "Number of work tasks that may execute concurrently in this configuration profile." - } - }, - "work_session": { - "max_age_seconds": { - "description": "Work-session retention", - "hint": "How many seconds to retain completed, failed, or cancelled work for status queries." - } - }, - "plugin_routes": { - "description": "Plugin tool loop assignments", - "hint": "Plugin LLM tools default to the work loop; explicitly assign an enabled plugin to the conversation loop or both when needed." - }, - "mcp_routes": { - "description": "MCP server loop assignments", - "hint": "MCP tools default to the work loop; explicitly assign an enabled server to the conversation loop or both when needed." - }, - "skill_routes": { - "description": "Skill loop assignments", - "hint": "Skills default to both loops; explicitly restrict an enabled Skill to one loop when needed." - } - } } }, "ext_group": { @@ -2130,8 +2073,5 @@ "documentation": "in-app documentation", "helpPrefix": "Don't understand the configuration? See the", "helpSuffix": "." - }, - "btw": { - "name": "BTW dual loops" } } diff --git a/dashboard/src/i18n/locales/en-US/features/config.json b/dashboard/src/i18n/locales/en-US/features/config.json index 8a5d1f44fb..828c1c950b 100644 --- a/dashboard/src/i18n/locales/en-US/features/config.json +++ b/dashboard/src/i18n/locales/en-US/features/config.json @@ -186,26 +186,6 @@ "fileCount": "Files: {count}", "done": "Done" }, - "pluginLoopSelector": { - "hint": "Plugin LLM tools default to Work only. You can explicitly allow Conversation only or both loops; plugin commands are outside this tool route.", - "plugin": "Plugin", - "loop": "Available loop", - "conversation": "Conversation only", - "work": "Work only", - "both": "Conversation and Work", - "empty": "There are no enabled non-system plugins." - }, - "capabilityLoopSelector": { - "mcpHint": "MCP tools default to Work only. Expose a server to the conversation loop only when it is appropriate for chat-time use.", - "skillHint": "Skills default to both loops. Workspace Skills remain available only to the work loop.", - "capability": "Capability", - "loop": "Available loop", - "conversation": "Conversation only", - "work": "Work only", - "both": "Conversation and Work", - "emptyMcp": "There are no enabled MCP servers.", - "emptySkill": "There are no enabled Skills." - }, "unsavedChangesWarning": { "dialogTitle": "Unsaved changes", "leavePage": "You have unsaved changes. Do you want to save before leaving?", diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index d5259aa082..31fbf78b39 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1168,63 +1168,6 @@ "description": "可用插件", "hint": "默认启用全部未被禁用的插件。若插件在插件页面被禁用,则此处的选择不会生效。" } - }, - "btw": { - "description": "BTW 双循环", - "btw": { - "enabled": { - "description": "启用 BTW 双循环", - "hint": "实验性原型,默认关闭。开启后由对话循环统一接收消息,并将显式工作请求转入工作循环;高风险工具动作仍然按上游规则拒绝,IM 不提权。" - }, - "classifier": { - "enabled": { - "description": "启用任务分类", - "hint": "可选的启发式规则,默认关闭。开启后按内置规则把疑似工作请求转入工作循环;/work 指令不依赖此开关。" - } - }, - "conversation_loop": { - "provider_id": { - "description": "对话循环模型", - "hint": "留空时使用当前会话的默认对话模型。对话循环不会获得本地、沙盒或文件工具。" - } - }, - "work_loop": { - "enabled": { - "description": "启用工作循环", - "hint": "关闭后,所有请求都由对话循环处理。" - }, - "provider_id": { - "description": "工作循环模型", - "hint": "留空时使用当前会话的默认对话模型。配置后会优先于会话模型选择。" - }, - "computer_use_runtime": { - "description": "工作循环电脑权限", - "hint": "inherit 使用现有电脑使用配置;local 和 sandbox 只会暴露给工作循环。" - }, - "max_concurrent": { - "description": "工作循环最大并发数", - "hint": "同一配置文件中可同时执行的工作任务数量。" - } - }, - "work_session": { - "max_age_seconds": { - "description": "工作会话保留时长", - "hint": "已完成、失败或取消的工作任务保留多少秒以供状态查询。" - } - }, - "plugin_routes": { - "description": "插件工具循环分配", - "hint": "插件 LLM 工具默认仅在工作循环可用;可为每个已启用插件显式改为对话循环或两者。" - }, - "mcp_routes": { - "description": "MCP 服务器循环分配", - "hint": "MCP 工具默认仅在工作循环可用;可为每个已启用服务器显式改为对话循环或两者。" - }, - "skill_routes": { - "description": "Skills 循环分配", - "hint": "Skill 默认注入两个循环;可为每个已启用 Skill 显式限制到单一循环。" - } - } } }, "ext_group": { @@ -2120,8 +2063,5 @@ "documentation": "内置文档", "helpPrefix": "不了解配置?请见", "helpSuffix": "。" - }, - "btw": { - "name": "BTW 双循环" } } diff --git a/dashboard/src/i18n/locales/zh-CN/features/config.json b/dashboard/src/i18n/locales/zh-CN/features/config.json index 517b10d4f1..4704a0ecd6 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config.json @@ -186,26 +186,6 @@ "fileCount": "文件:{count}", "done": "完成" }, - "pluginLoopSelector": { - "hint": "插件 LLM 工具默认仅在工作循环可用。可显式改为仅对话循环或两个循环;插件命令不受此工具路由控制。", - "plugin": "插件", - "loop": "可用循环", - "conversation": "仅对话循环", - "work": "仅工作循环", - "both": "对话与工作循环", - "empty": "当前没有已启用的非系统插件。" - }, - "capabilityLoopSelector": { - "mcpHint": "MCP 工具默认仅在工作循环可用。仅在确认服务器适合聊天调用时,才显式开放给对话循环。", - "skillHint": "Skill 默认注入两个循环;工作区 Skill 仍仅在工作循环中可用。", - "capability": "能力", - "loop": "可用循环", - "conversation": "仅对话循环", - "work": "仅工作循环", - "both": "对话与工作循环", - "emptyMcp": "当前没有已启用的 MCP 服务器。", - "emptySkill": "当前没有已启用的 Skill。" - }, "unsavedChangesWarning": { "dialogTitle": "未保存的更改", "leavePage": "当前配置有未保存的更改,切换前是否保存?", diff --git a/dashboard/tests/capabilityLoopSelector.vitest.ts b/dashboard/tests/capabilityLoopSelector.vitest.ts deleted file mode 100644 index 80bbfc3eb7..0000000000 --- a/dashboard/tests/capabilityLoopSelector.vitest.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { flushPromises } from '@vue/test-utils'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import CapabilityLoopSelector from '@/components/shared/CapabilityLoopSelector.vue'; -import { mountWithVuetify } from './utils/mountWithVuetify'; - -const testState = vi.hoisted(() => ({ - mcpListMock: vi.fn(), - skillListMock: vi.fn(), -})); - -vi.mock('@/api/v1', () => ({ - mcpApi: { - list: testState.mcpListMock, - }, - skillApi: { - list: testState.skillListMock, - }, -})); - -describe('CapabilityLoopSelector', () => { - beforeEach(() => { - testState.mcpListMock.mockResolvedValue({ - data: { - status: 'ok', - data: [ - { name: 'workspace-mcp', active: true }, - { name: 'disabled-mcp', active: false }, - ], - }, - }); - testState.skillListMock.mockResolvedValue({ - data: { - status: 'ok', - data: { - skills: [ - { name: 'workspace-skill', active: true }, - { name: 'disabled-skill', active: false }, - ], - }, - }, - }); - }); - - it('defaults MCP servers to work and preserves an explicit both override', async () => { - const wrapper = mountWithVuetify(CapabilityLoopSelector, { - props: { - kind: 'mcp', - modelValue: [], - }, - }); - - await flushPromises(); - - expect(wrapper.text()).toContain('workspace-mcp'); - expect(wrapper.text()).not.toContain('disabled-mcp'); - - const select = wrapper.findComponent({ name: 'VSelect' }); - expect(select.props('modelValue')).toBe('work'); - - select.vm.$emit('update:modelValue', 'both'); - await wrapper.vm.$nextTick(); - - expect(wrapper.emitted('update:modelValue')).toEqual([ - [[{ server_name: 'workspace-mcp', loop: 'both' }]], - ]); - wrapper.unmount(); - }); - - it('uses Skill names as the assignment key', async () => { - const wrapper = mountWithVuetify(CapabilityLoopSelector, { - props: { - kind: 'skill', - modelValue: [], - }, - }); - - await flushPromises(); - - expect(wrapper.text()).toContain('workspace-skill'); - expect(wrapper.text()).not.toContain('disabled-skill'); - - const select = wrapper.findComponent({ name: 'VSelect' }); - select.vm.$emit('update:modelValue', 'conversation'); - await wrapper.vm.$nextTick(); - - expect(wrapper.emitted('update:modelValue')).toEqual([ - [[{ skill_name: 'workspace-skill', loop: 'conversation' }]], - ]); - wrapper.unmount(); - }); -}); diff --git a/dashboard/tests/pluginLoopSelector.vitest.ts b/dashboard/tests/pluginLoopSelector.vitest.ts deleted file mode 100644 index 40c3501fab..0000000000 --- a/dashboard/tests/pluginLoopSelector.vitest.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { flushPromises } from '@vue/test-utils'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import PluginLoopSelector from '@/components/shared/PluginLoopSelector.vue'; -import { mountWithVuetify } from './utils/mountWithVuetify'; - -const testState = vi.hoisted(() => ({ - pluginListMock: vi.fn(), -})); - -vi.mock('@/api/v1', () => ({ - pluginApi: { - list: testState.pluginListMock, - }, -})); - -describe('PluginLoopSelector', () => { - beforeEach(() => { - testState.pluginListMock.mockResolvedValue({ - data: { - status: 'ok', - data: [ - { - name: 'example-plugin', - root_dir_name: 'example-plugin', - display_name: 'Example Plugin', - activated: true, - reserved: false, - }, - { - name: 'system-plugin', - activated: true, - reserved: true, - }, - ], - }, - }); - }); - - it('defaults plugin tools to work and preserves an explicit both override', async () => { - const wrapper = mountWithVuetify(PluginLoopSelector, { - props: { - modelValue: [], - }, - }); - - await flushPromises(); - - expect(wrapper.text()).toContain('Example Plugin'); - expect(wrapper.text()).not.toContain('system-plugin'); - - const select = wrapper.findComponent({ name: 'VSelect' }); - expect(select.props('modelValue')).toBe('work'); - - select.vm.$emit('update:modelValue', 'both'); - await wrapper.vm.$nextTick(); - - expect(wrapper.emitted('update:modelValue')).toEqual([ - [[{ plugin_id: 'example-plugin', loop: 'both' }]], - ]); - - await wrapper.setProps({ - modelValue: [{ plugin_id: 'example-plugin', loop: 'both' }], - }); - select.vm.$emit('update:modelValue', 'work'); - await wrapper.vm.$nextTick(); - - expect(wrapper.emitted('update:modelValue')).toEqual([ - [[{ plugin_id: 'example-plugin', loop: 'both' }]], - [[]], - ]); - wrapper.unmount(); - }); -}); diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 5334996f6c..21b9e6e90a 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -182,6 +182,7 @@ export default defineConfig({ collapsed: true, items: [ { text: '项目架构', link: '/architecture' }, + { text: 'BTW 双循环设计(提案)', link: '/btw-dual-loop' }, { text: '源码开发', link: '/development' }, { text: 'Linux 开发环境', link: '/linux' }, { @@ -443,6 +444,10 @@ export default defineConfig({ collapsed: true, items: [ { text: 'Architecture', link: '/architecture' }, + { + text: 'BTW Dual-Loop Design (Proposal)', + link: '/btw-dual-loop', + }, { text: 'Source Development', link: '/development' }, { text: 'Linux Development', link: '/linux' }, { diff --git a/docs/en/dev/architecture.md b/docs/en/dev/architecture.md index 73d29df6db..c56031743f 100644 --- a/docs/en/dev/architecture.md +++ b/docs/en/dev/architecture.md @@ -166,7 +166,7 @@ The order in `astrbot/core/pipeline/stage_order.py` is: `GroupMessageHistoryStage` persists inbound group messages other than WebChat before any plugin handles the event, for `GetGroupMessageHistoryTool`. Direct messages and WebChat skip this stage. `ProcessStage` runs plugin handlers and the Agent. `ResultDecorateStage` applies prefixes, segmentation, TTS, local text-to-image rendering, quoting, and related transformations. `RespondStage` uses the platform's unified send API. The scheduler supports both ordinary async stages and async-generator onion middleware; preserve stop-propagation and finalization semantics. `SessionStatusCheckStage` stops events when the session is disabled, except for activated `/bot status` and `/bot enable` so the session can be turned back on from chat. -Inbound routing is a single decision in `WakingCheckStage`: command, LLM, passthrough, or drop. It writes `should_run_command`, `should_run_llm`, `route_kind`, and the explicit `wake_reasons` set onto the event. Command matching runs before LLM access; a matched command normally only runs the command, a bare command group emits help, and an unknown subcommand emits the Orbit diagnostic without falling through to the LLM. Built-in `/work ` is the exception: the handler rewrites `message_str` and sets `should_run_llm` plus `btw_force_work` so `ProcessStage` continues into the work loop after the command returns. That path still requires `btw.enabled` and `btw.work_loop.enabled` on the profile, and its `command_id` is `builtin_commands:work`. LLM access is selected from the event's configuration profile through `llm_access`; `command_prefixes` only frames command headers. The derived `is_wake` attribute is not a pipeline gate. +Inbound routing is a single decision in `WakingCheckStage`: command, LLM, passthrough, or drop. It writes `should_run_command`, `should_run_llm`, `route_kind`, and the explicit `wake_reasons` set onto the event. Command matching runs before LLM access; a matched command wins, a bare command group emits help, and an unknown subcommand emits the Orbit diagnostic without falling through to the LLM. LLM access is selected from the event's configuration profile through `llm_access`; `command_prefixes` only frames command headers. The derived `is_wake` attribute is not a pipeline gate. `TurnCoalesceStage` runs after the allow-list and session checks. When enabled, it hands eligible private-message LLM fragments to the lifecycle-owned, bounded `TurnWindowManager` without waiting in the pipeline. The manager merges fragments, pauses on NapCat typing notices, discards a buffered turn when a command arrives, and requeues one signed flush event through rate limiting and the remaining stages. Adapter-supplied flush flags are stripped; only manager-created events can carry `route_kind=turn_flush`. Notice and request events remain passthrough events, so ephemeral `input_status` never becomes an LLM message. diff --git a/docs/en/dev/astrbot-config.md b/docs/en/dev/astrbot-config.md index e5affd6e3d..509a4e8b75 100644 --- a/docs/en/dev/astrbot-config.md +++ b/docs/en/dev/astrbot-config.md @@ -37,7 +37,6 @@ At startup, AstrBot recursively inserts missing current defaults, fixes key orde | `agent_runner` | Agent Runner type and inline configuration for this profile. | | `provider_settings` | Shared AI switch, retrieval, streaming, and Computer Use behavior for this profile. | | `subagent_orchestrator` | SubAgent handoff orchestration. | -| `btw` | Conversation-loop entry point, rule-based task classification, work loop, and plugin/MCP/Skill loop assignments. | | `provider_stt_settings` / `provider_tts_settings` | Default speech-to-text and text-to-speech models and switches. | | `provider_ltm_settings` | [Group chat context awareness](../use/group-chat-context) (in-memory group context, image captions, persisted group history). The JSON key is still historical; it is not the Alkaid long-term-memory switch. Random group proactive replies have been removed. | | `content_safety` | Built-in keyword checks and optional external content-safety checks. | @@ -52,7 +51,7 @@ Object layouts inside `provider_sources`, `provider`, and `platform` come from t ## Inbound routing -`command_prefixes` and `llm_access` are read from the configuration profile selected for the event. `command_prefixes` only frames command headers; it is never combined with an LLM prefix. Each `llm_access.prefixes` entry is the complete string users type, uses token-boundary matching, and follows longest-match semantics. Non-empty LLM prefixes reserve their first command-root token in the same profile, so a prefix that conflicts with an enabled command is rejected by the Dashboard. +User-facing steps are in [When the bot replies in groups](../use/group-wake). `command_prefixes` and `llm_access` are read from the configuration profile selected for the event. `command_prefixes` only frames command headers; it is never combined with an LLM prefix. Each `llm_access.prefixes` entry is the complete string users type, uses token-boundary matching, and follows longest-match semantics. Non-empty LLM prefixes reserve their first command-root token in the same profile, so a prefix that conflicts with an enabled command is rejected by the Dashboard. | Key | Values | Meaning | | ------------------------------------ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | @@ -187,30 +186,6 @@ Local mode operates directly on the AstrBot host and belongs only in a trusted e `image_compress_enabled` and `image_compress_options.max_size/quality` control image handling in the request-preparation choke point `prepare_provider_request`. The main-agent chat path, SDK `llm_generate`, and `tool_loop_agent` share that step. Provider-bound images are converted to JPEG there; the long edge is downscaled only and never upscaled. Animated GIF/WebP sources are dhash-sampled, at most 8 frames. Disabling compression still converts to JPEG without resizing. The main agent only materializes adapter refs to local paths and does not pre-encode chat attachments to JPEG. `max_quoted_fallback_images` and `quoted_message_parser` limit quoted and forwarded-message expansion to prevent unbounded fetching. For `quoted_message_parser`, `0` is a valid boundary: depth limits keep the root level but stop child recursion, and `max_forward_fetch=0` disables recursive `get_forward_msg` calls. Negative or invalid values fall back to defaults; this setting does not globally disable a direct quoted-message `get_msg` fallback. -## BTW dual-loop prototype - -`btw` provides one entry point for the current dual-loop prototype. Every message first enters the conversation loop. With rule-based classification enabled, requests about code, files, commands, search, research, or coding agents such as Claude Code, Codex, OpenCode, and HAPI are sent to the work loop. `/work ` is a built-in command that submits the remaining free text to the work loop without the classifier; `/work` and `/work status` query the newest task status. The work loop reuses the established Agent and tool execution path; core does not provide a dedicated Codex, CC, or other coding-agent executor. The source-built Docker image does preinstall the `claude` and `codex` CLIs, but they are callable only through work-loop shell tools or an external plugin. - -- `btw.enabled` is the master switch. When disabled, every request still uses the existing Agent path through the conversation loop. -- `btw.classifier.enabled` enables the built-in deterministic rules. When disabled, requests are not automatically sent to the work loop. -- `btw.conversation_loop.provider_id` selects the conversation-loop model. Empty uses the session default; a value takes precedence over a session model selection. -- `btw.work_loop.enabled` enables the work loop; `max_concurrent` limits classified work tasks that can execute at the same time in this profile. -- `btw.work_loop.provider_id` selects the work-loop model, which may differ from the conversation Provider. Empty uses the session default. -- `btw.work_loop.computer_use_runtime` controls computer permission for the work loop. `inherit` uses the existing `provider_settings.computer_use_runtime`; `none`, `local`, and `sandbox` set it explicitly. -- The work loop receives no IM elevation: high-risk `tool.*` actions stay Dashboard-only from IM even when the work loop runs. Privilege isolation is configured per profile through `computer_use_runtime`; the conversation loop hard-disables these tools, and the work loop's runtime choice (`none`, `local`, `sandbox`) is the only control plane. -- `btw.work_session.max_age_seconds` is the retention period for terminal work sessions. It defaults to `3600` seconds and is cleaned up lazily by the next session operation. -- `btw.plugin_routes` lets you choose **Conversation only**, **Work only**, or **Conversation and Work** for every enabled non-system plugin on the **Config** page. No saved entry defaults to **Work only**; choosing both loops is stored as an explicit override. -- `btw.mcp_routes` uses the same choice for every enabled MCP server. No saved entry also defaults to **Work only**, so execution-oriented servers such as `mcp__codex__codex` do not silently enter the conversation loop. -- `btw.skill_routes` uses the same choice for every enabled Skill. Ordinary Skills default to both loops, while workspace Skills remain work-loop-only. - -The conversation loop forcibly disables local computer, sandbox, browser, and filesystem tools. Only the work loop can receive those capabilities. Plugin assignments filter plugin LLM tools, MCP assignments filter all tools provided by each MCP server, and Skill assignments filter which Skill prompts are injected. Existing subagent handoffs receive the same tool routes and cannot regain computer tools from the conversation loop. LLM tools registered by external Claude Code, Self Code, HAPI, Codex app-server, and OpenCode plugins therefore default to the work loop. - -Plugin Pipeline/Star handlers and explicit commands such as `/hapi`, `/codexdev`, `/vibe`, and `/oc` retain the plugin's existing priority and are outside LLM tool routing. Moving those commands into detached work sessions requires explicit plugin support or a future command-execution protocol; a work-only plugin tool assignment does not migrate the entire plugin. - -The work loop first replies that the task has started, then continues in a runtime-owned background task. Its results replay the result-decoration stage onward, which includes the reply content-safety check, TTS/T2I decoration, and platform delivery; inbound stages (waking, rate limit, inbound content safety) are not re-run. Background work uses a separate session lock, so it does not block later chat in the same session. Work sessions are runtime-only in-memory state; query them with `/work` or `/work status`. The state is not retained after a restart or runtime rebuild. The command identity is `builtin_commands:work`. - -These settings belong to a configuration profile. Check the BTW switches, concurrency, and plugin-tool assignments separately for every profile. - ## SubAgents, speech, and knowledge base - `subagent_orchestrator.main_enable` enables handoffs. @@ -256,7 +231,7 @@ Dashboard accounts have stable `account_id` values. Their TOTP secret, recovery- - Providers and platforms use three-state `proxy_mode`: `inherit` follows the global config, `direct` disables environment proxies, and `custom` uses only that item's `proxy_url`. An empty string no longer means both inherit and direct. - No GitHub mirrors are provided by default. Plugin `download_url` values and prefix mirrors must be public HTTPS origins; private and non-HTTPS targets are rejected. - `platform_settings.segmented_reply` remains a UX feature and stays off by default. Telegram, Discord, and WeCom hard-limit splitting is handled by the send path. -- `log_level` and `log_file_*` control the console Loguru sink, the root logger, plugin loggers without an override, and rotating file logs. `log_level` applies to terminal output, not only the file sink. +- `log_level` and `log_file_*` control the console Loguru sink, the root logger, plugin loggers without an override, and rotating file logs. `log_level` applies to terminal output, not only the file sink. File logs use the same redacting sink: recognized secret fields, Bearer tokens, URLs, and absolute paths are replaced before write. Cookies, private chat, and custom secrets are not guaranteed; review logs before sharing. - `trace_enable` is the Trace collection switch; `trace_log_*` controls its separate rotating file. - `temp_dir_max_size` limits `data/temp` in MiB and defaults to `1024`; a background task removes older files when the limit is exceeded. - `timezone` is an IANA timezone and defaults to `Asia/Shanghai`. diff --git a/docs/en/dev/btw-dual-loop.md b/docs/en/dev/btw-dual-loop.md new file mode 100644 index 0000000000..ea06ccbee4 --- /dev/null +++ b/docs/en/dev/btw-dual-loop.md @@ -0,0 +1,105 @@ +--- +outline: deep +--- + +# BTW Dual-Loop Design + +This page records the direction and capability breakdown for [PR #28](https://github.com/Xero-Team/AstrBot/pull/28). That PR delivers documentation only. The loops, commands, and settings described below are proposed follow-up work, not features available in the current release. + +## Goal and Responsibilities + +BTW aims to let users keep talking while a longer task runs, check its status, and receive its result. Conversation and work reuse the existing Agent execution path with different responsibilities. + +| Loop | Responsibility | Intended capabilities | +| ------------ | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| Conversation | Understand requests, clarify requirements, handle requests within its abilities, and keep the interaction going | The model, tools, and Skills actually available to the current request | +| Work | Accept explicitly submitted tasks or future handoffs from conversation, track execution, and return results | Capabilities assigned to work, subject to existing authorization | + +The work loop is not a new permission level, and a handoff grants no additional permissions. Capability assignment determines what each loop can see. Task routing determines which loop handles a particular request. These concerns can progress separately. + +## Current Decisions + +- PR #28 retains this design document only. The prototype remains historical reference material; a parent Issue and Sub-issues will track discussion, implementation, and acceptance of each capability. +- Follow-up implementation starts from current master and reuses its message pipeline, Agent runners, tool catalog, and Skill snapshots. Superseded assembly and authorization logic from the prototype must not be copied back. +- The initial direction retains explicit entry and disabled defaults: `/work ` submits work. The prototype rule classifier is a reference candidate tested in its own experimental PR, not a predetermined production default. +- The intended later direction is: **routing belongs inside the conversation loop. The loop knows its available capabilities, handles requests it can fulfill, and hands requests that need work capabilities to the work loop.** +- Product integration of that routing direction is deferred. **Different classifiers belong in separate PRs, starting from the same baseline and tested separately under one evaluation protocol.** Results will inform the handoff contract and approach to adopt; these experiments do not block the other capability slices. + +The reference prototype is commit [`33ee103a62937db3e930c89ba47a648b75cc7772`](https://github.com/Xero-Team/AstrBot/commit/33ee103a62937db3e930c89ba47a648b75cc7772). In that version, `ConversationLoop.process()` applies rules or accepts an explicit work marker before the model call. It does not implement a conversation model deciding to hand off based on its own capabilities. + +## Capability Breakdown + +B1–B10 below are proposed feature Sub-issues; R1–R3 each correspond to a separate classifier experiment PR. These are discussion identifiers, not GitHub Issue numbers. Each slice includes its settings, Dashboard interactions, tests, and bilingual documentation instead of splitting all frontend and backend work into separate tickets. This PR neither implements nor validates the acceptance requirements in the table. + +| ID | Sub-issue scope | Acceptance focus | +| --- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| B1 | Opt-in dual-loop entry and ordinary conversation | Disabled mode preserves the current Agent path and capabilities; enabled mode admits ordinary requests to conversation; automatic-classifier experiments are not a prerequisite. | +| B2 | Explicit task entry with `/work ` | A registered built-in command receives the full task text; submission works with classification off; disabled work returns a clear message; command permissions and session LLM switches remain effective. | +| B3 | Background execution, concurrency, and lifecycle | Acknowledge receipt before background execution while conversation remains available; bound concurrency; verify pending, running, completed, failed, and cancelled states; reclaim tasks at runtime shutdown. | +| B4 | Result delivery and event resource release | Return results to the originating request through reply checks, decoration, and delivery; the acknowledgement must not prematurely end a WebChat request; release temporary files and event registrations at completion. | +| B5 | Work status and retention | `/work` and `/work status` report the latest task for the current profile and session; empty, terminal, and expired states are defined; profiles remain isolated and group-chat query scope is explained. | +| B6 | Model selection per loop | Conversation and work may choose different models; empty selections inherit the current choice; invalid or unavailable selections have defined behavior; disabling BTW preserves ordinary model selection. | +| B7 | Computer Use boundaries per loop | Conversation does not mount Computer Use; work may inherit or select `none`, `local`, or `sandbox`; handoffs retain the same restrictions and loop selection does not alter permissions. | +| B8 | Assign plugin LLM tools to loops | Select conversation, work, or both per plugin; the prototype defaults to work; main Agent and handoff behavior agree. Only LLM tools are affected, not plugin event handlers or the execution destination of explicit commands. | +| B9 | Assign MCP tools to loops by server | Tools from one MCP server follow a shared assignment; the prototype defaults to work; saved settings, main Agent, and handoff behavior agree while preserving MCP connection and authorization boundaries. | +| B10 | Skill visibility per loop | Ordinary Skills defaulted to both in the prototype; workspace Skills retain the work-loop and local-runtime boundary; prompts, `read_skill`, and declared tools use consistent filtering; reading a Skill does not require Shell permission. | +| R1 | Rule-classifier experiment PR | Use prototype keywords and deterministic rules as a reference; test boundaries, everyday queries, and mistakes when capabilities change; report results independently. | +| R2 | Separate model-classifier experiment PR | Call a model before conversation execution to choose a destination; measure classification quality and the latency and cost of the additional call without presuming this is the final architecture. | +| R3 | Capability-aware decision inside conversation, in its own PR | Let the conversation model handle or hand off requests based on available capabilities; measure unnecessary and missed handoffs and context continuity. This is the currently preferred direction to investigate. | + +### Dependencies and Delivery Order + +B1 defines the enablement boundary. B2–B5 together provide “submit → execute in the background → return results → inspect status.” An intermediate state that can acknowledge a task but cannot return its result is not a usable feature. These slices can be reviewed separately, but the first usable version needs the complete path. + +B6 and B7 define each loop's model and runtime. B8–B10 deliver capability assignment separately. Shared catalog filtering or configuration controls belong to the first slice that needs them. Acceptance covers BTW enabled and disabled, and both main Agent and handoff paths. + +R1–R3 are parallel alternatives with no dependency on one another; they are not a sequence of classifier implementations stacked on each other. Agree on a common baseline and evaluation protocol, then implement, test, and review each separately. Move all candidates to the same updated baseline when integration testing needs real work execution. Product integration remains a later decision after the comparison and after execution, delivery, and capability-assignment contracts are clear. + +## Routing Experiment Boundaries + +### Separate PRs and a Shared Evaluation Protocol + +R1–R3 are initial candidates whose scope can be refined in their Sub-issues. Each PR contains one classifier implementation and its tests, recording the baseline commit, dataset version, capability fixtures, model, and parameters. Establish common cases and the evaluation harness first so implementations do not select different datasets to demonstrate success. + +Run the same offline cases in each PR, followed by the same integration scenarios on a shared working dual-loop baseline. Deterministic tests check contracts; model trials separately report variation across repeated runs. Report both forms of evidence separately. Explicit `/work` entry is a control baseline for every approach, not a fourth classifier. + +The parent Issue collects results, costs, and trade-offs from each PR. Do not merge all candidates together before comparison or add a production framework for switching classifiers in advance. Review integration of the selected approach separately; retain unselected implementations as experiment records. + +### Capability Visibility + +Conversation should see the resolved capabilities of the current request, not merely the names of every installed tool. Experiment inputs include the current model, the tool catalog after configuration and Persona filtering, the Skill snapshot, runtime restrictions, and the range of tasks that work can accept. + +Use current `astrbot/core/tool_catalog.py`, main-agent catalog assembly, and Skill snapshots as the sources of truth. Avoid maintaining another capability list that can drift. Appearance in a catalog or Skill does not grant execution permission; the existing authorization service still decides at execution time. + +### Questions to Resolve + +- Can conversation make a different handling or handoff decision for the same request when its available capabilities change? +- Can it distinguish “needs work capabilities,” “lacks authorization,” “needs clarification,” and “neither loop can complete this,” instead of handing off every failure? +- Should handoff happen before the first action, or also after execution reveals a capability gap? If the latter is useful, how will it avoid repeating steps that already produced side effects? +- Which context, completed steps, and expected results must accompany a handoff for work to continue the same task? + +These are experiment questions. This document does not prescribe a new tool interface, prompt format, or routing service. + +### Cases and Measurements + +Cases should cover ordinary chat, queries solvable with available tools, workspace or external execution, mixed requests, incomplete requirements, unavailable tools, insufficient permissions, and requests neither loop can fulfill. Pair the same request with different capability sets to test whether decisions actually depend on capability availability. + +Measure unnecessary and missed handoffs, task completion, appropriate clarification and refusal, added latency, model calls and token cost, repeated handoffs, and duplicate execution. Start with fixed data and simulated tools rather than enabling routing in production as an experiment. State acceptance thresholds before evaluation; retaining explicit entry is a valid outcome. + +## Implementation and Acceptance Constraints + +- **Current path:** Reuse `AgentRequestSubStage`, the current tool catalog, and Skill snapshots. Assess external Agent runners separately: local-runner control over models and tools does not automatically apply to a remote service. +- **Authorization:** Preserve current configuration scope, role, and entry rules. BTW markers, routing decisions, and Skill declarations are not authorization credentials. This design does not restore the prototype's proposed BTW-specific elevation mechanism. +- **Request identity:** WebChat acknowledgements, `run_started`, per-model-call `agent_stats`, streamed results, completion, and interrupts stay attached to the original `message_id`. Concurrent tasks must not collapse into a session-wide busy flag. +- **Lifecycle:** Runtime owns background tasks and cancellation propagates. Verify cancellation while queued, execution and delivery failure, configuration reload, and shutdown cleanup. Terminal-state retention must not expire active tasks. +- **Configuration:** Use the current profile-save path and one configuration shape, with disabled defaults. Do not add compatibility for old prototype dictionaries. The UI must explain that ordinary Skills default to both while plugin and MCP tools default to work. +- **Verification:** Use the nearest existing backend and Dashboard tests and add regressions for each observable behavior. Background integration requires the real scheduler and WebChat protocol; a successful mock-dispatcher test does not establish end-to-end behavior. +- **Docs and interfaces:** Update command, configuration, and topic documentation in both languages as features land. Synchronize OpenAPI and generated artifacts only when the HTTP contract actually changes; this design does not require new HTTP endpoints. + +## Parent Issue and Non-goals + +The proposed parent Issue, “BTW dual-loop: capability breakdown and classifier comparison,” tracks B1–B10, dependencies, acceptance, and the separate R1–R3 experiment PRs and results. Product integration of classification remains deferred. Create the GitHub parent and native Sub-issues after discussing the slices, then replace these discussion identifiers with real links. + +This increment excludes persistent or resumable work, cross-device scheduling, an automatic retry or rollback platform, a multi-task management page, and enabling automatic routing by default in production. The prototype's `pyupgrade` adjustment is separate repository maintenance, outside the BTW feature scope. + +Current behavior is documented in [Architecture](./architecture.md), [Computer Use](../use/computer.md), and [Skills](../use/skills.md). The directions and acceptance requirements here need agreement in follow-up Issues and do not replace implementation review. diff --git a/docs/en/use/command.md b/docs/en/use/command.md index a5f89610c0..59c53dd081 100644 --- a/docs/en/use/command.md +++ b/docs/en/use/command.md @@ -82,8 +82,6 @@ The user ID from `/session info` can be granted current-session `session_admin` ### Running Tasks - `/task stop`: Stop running Agent or third-party Agent Runner tasks in the current session without deleting history. -- `/work `: Submit the remaining free text to the BTW work loop. It does not require the `/chat` prefix or the task classifier. Requires `session.read`, with `btw.enabled` and `btw.work_loop.enabled` on the profile. The command identity is `builtin_commands:work`. -- `/work` or `/work status`: Show the newest work-task status for this session. `status` is a status query only when it is the entire remainder (case-insensitive); `/work status refactor` is submitted as a task. Requires `session.read`. Status lives in runtime memory and is cleared on restart. ### Providers and Models diff --git a/docs/en/use/computer.md b/docs/en/use/computer.md index 851bb0f49a..ba65ae11bc 100644 --- a/docs/en/use/computer.md +++ b/docs/en/use/computer.md @@ -75,7 +75,7 @@ Computer Use uses the unified authorization service. There is no “Require Astr - `tool.file_write` - `tool.browser_control` -`tool.file_read` is available to current-session members and above, still subject to path limits. `tool.local_exec`, `tool.python_exec`, `tool.file_write`, `tool.browser_control`, and `tool.computer_use` are high-risk: an authenticated Dashboard-driven WebChat may use them only in its current session/config after the WebChat one-time step-up. Global control-plane actions remain Dashboard-only; anonymous WebChat, IM, plugins, agents, and API keys do not inherit Dashboard roles. IM never inherits any high-risk action: the BTW work loop adds no elevation path, so `tool.*` high-risk actions stay Dashboard-only from IM regardless of configuration. Sandbox, path, Persona, and declared-tool restrictions still apply. +`tool.file_read` is available to current-session members and above, still subject to path limits. `tool.local_exec`, `tool.python_exec`, `tool.file_write`, `tool.browser_control`, and `tool.computer_use` are high-risk: an authenticated Dashboard-driven WebChat may use them only in its current session/config after the WebChat one-time step-up. Global control-plane actions remain Dashboard-only; anonymous WebChat, IM, plugins, agents, and API keys do not inherit Dashboard roles. Sandbox, path, Persona, and declared-tool restrictions still apply. In `local` mode, ordinary session members may read: @@ -85,7 +85,7 @@ In `local` mode, ordinary session members may read: - AstrBot temporary directories - `.astrbot` under the system temporary directory -Writes and edits remain limited to the current session workspace and temporary directories. Grant matching actions from the Dashboard [authorization page](/en/use/webui#accounts-and-authorization). `/admin grant` only creates current-session `session_admin`; it does not turn an IM user into a global operator. See [Architecture](/en/dev/architecture#unified-authorization) for the developer model. +Writes and edits remain limited to the current session workspace and temporary directories. Grant matching actions from the Dashboard [authorization page](/en/use/authorization). `/admin grant` only creates current-session `session_admin`; it does not turn an IM user into a global operator. See [Architecture](/en/dev/architecture#unified-authorization) for the developer model. ## Sandbox Mode diff --git a/docs/zh/dev/architecture.md b/docs/zh/dev/architecture.md index e0be0e706e..834fa415c5 100644 --- a/docs/zh/dev/architecture.md +++ b/docs/zh/dev/architecture.md @@ -166,7 +166,7 @@ Mixin 通过带类型的 `store_session(self)` 助手获取会话,不直接持 `GroupMessageHistoryStage` 在插件处理前持久化非 WebChat 的入站群消息,供 `GetGroupMessageHistoryTool` 使用;私聊和 WebChat 会跳过。`ProcessStage` 负责插件处理与 Agent 调用;`ResultDecorateStage` 处理前缀、分段、TTS、本地文转图、引用等结果装饰;`RespondStage` 统一调用平台发送接口。流水线同时支持普通异步 stage 和用异步生成器实现的洋葱式前后处理,修改时必须保留停止传播和收尾语义。`SessionStatusCheckStage` 在会话关闭时停止事件,但放行已激活的 `/bot status` 和 `/bot enable`,以便从聊天重新打开会话。 -入站路由在 `WakingCheckStage` 中一次完成:指令、LLM、透传或丢弃。阶段会把 `should_run_command`、`should_run_llm`、`route_kind` 和明确的 `wake_reasons` 集合写入事件。指令匹配优先于 LLM 访问:命中指令时默认只执行指令,裸指令组输出帮助,未知子指令输出 Orbit 诊断且不会回落到 LLM。内置 `/work <任务>` 是例外:handler 会改写 `message_str`,并设置 `should_run_llm` 与 `btw_force_work`,让 `ProcessStage` 在指令返回后继续进入工作循环;该路径仍要求配置档启用 `btw.enabled` 与 `btw.work_loop.enabled`,且 `command_id` 为 `builtin_commands:work`。LLM 访问从事件所属配置档的 `llm_access` 读取;`command_prefixes` 只负责指令头。派生属性 `is_wake` 不能作为 Pipeline 门禁。 +入站路由在 `WakingCheckStage` 中一次完成:指令、LLM、透传或丢弃。阶段会把 `should_run_command`、`should_run_llm`、`route_kind` 和明确的 `wake_reasons` 集合写入事件。指令匹配优先于 LLM 访问:命中指令时只执行指令,裸指令组输出帮助,未知子指令输出 Orbit 诊断且不会回落到 LLM。LLM 访问从事件所属配置档的 `llm_access` 读取;`command_prefixes` 只负责指令头。派生属性 `is_wake` 不能作为 Pipeline 门禁。 `TurnCoalesceStage` 位于白名单和会话检查之后。启用时,它把符合条件的私聊 LLM 消息片段交给生命周期持有的有界 `TurnWindowManager`,不会在流水线中等待。管理器负责合并片段、根据 NapCat 输入状态暂停、收到指令时丢弃未完成回合,并重新排队一个带签名的 flush 事件,让它经过限流及后续阶段。适配器提供的 flush 标志会被清除,只有管理器创建的事件可以携带 `route_kind=turn_flush`。通知和请求保持透传,因此临时的 `input_status` 不会变成 LLM 消息。 diff --git a/docs/zh/dev/astrbot-config.md b/docs/zh/dev/astrbot-config.md index ad412096ad..5b02e12c22 100644 --- a/docs/zh/dev/astrbot-config.md +++ b/docs/zh/dev/astrbot-config.md @@ -37,7 +37,6 @@ WebUI 创建的其他配置档位于 `data/config/abconf_.json`。消息 | `agent_runner` | 当前配置档的 Agent 执行器类型及其内联配置。 | | `provider_settings` | 当前配置档的 AI 开关、检索、流式输出、Computer Use 等共用行为。 | | `subagent_orchestrator` | 子代理 handoff 编排。 | -| `btw` | 对话循环入口、规则任务分类、工作循环,以及插件、MCP、Skill 的循环分配。 | | `provider_stt_settings` / `provider_tts_settings` | 语音转文本和文本转语音默认模型及开关。 | | `provider_ltm_settings` | [群聊上下文感知](../use/group-chat-context)(内存群聊上下文、图片转述、持久化群消息历史)。JSON 键仍为历史名称;不是 Alkaid 长期记忆开关。群聊随机主动回复已移除。 | | `content_safety` | 内置关键词和可选外部内容安全检查。 | @@ -46,12 +45,13 @@ WebUI 创建的其他配置档位于 `data/config/abconf_.json`。消息 | `command_prefixes` | 指令头前缀,默认 ["/"]。 | | `llm_access` | 当前配置档的私聊和群聊 LLM 访问策略;默认 `private=prefix`、`group=prefix`、`prefixes=["/"]`。 | | `inbound_coalesce` | 可选的连续私聊 LLM 消息有界合并,默认关闭。 | +| 其他顶层键 | 管理员、T2I、代理、日志、时区、插件、知识库、Trace 和指标等。 | `provider_sources`、`provider` 和 `platform` 中的对象结构由各类型注册的当前模板决定。不要从旧文档复制对象;在 WebUI 创建后再检查保存结果。模型通过 `provider_source_id` 引用来源,重命名或删除来源时应让 WebUI 同步引用。 ## 入站路由 -`command_prefixes` 和 `llm_access` 都读取事件实际选中的配置档。`command_prefixes` 只负责指令头,不会与 LLM 前缀自动拼接。`llm_access.prefixes` 的每一项都是用户实际输入的完整字符串,按词边界和最长匹配处理。非空 LLM 前缀会在同一配置档占用其第一个指令根;如果与已启用指令冲突,Dashboard 会拒绝保存。 +用户向步骤见 [群聊何时会理我](../use/group-wake)。`command_prefixes` 和 `llm_access` 都读取事件实际选中的配置档。`command_prefixes` 只负责指令头,不会与 LLM 前缀自动拼接。`llm_access.prefixes` 的每一项都是用户实际输入的完整字符串,按词边界和最长匹配处理。非空 LLM 前缀会在同一配置档占用其第一个指令根;如果与已启用指令冲突,Dashboard 会拒绝保存。 | 键 | 可选值 | 说明 | | ------------------------------------ | ------------------------- | ------------------------------------------------------------------------------------------------ | @@ -188,30 +188,6 @@ API Key 属于敏感配置。不要把真实 `cmd_config.json`、截图、日志 `image_compress_enabled` 和 `image_compress_options.max_size/quality` 控制请求准备卡口 `prepare_provider_request` 中的图片处理,主智能体聊天路径、SDK `llm_generate` 与 `tool_loop_agent` 共用该卡口。送给模型的图片在此处转为 JPEG,最长边只缩小、从不放大;动画 GIF/WebP 会按 dhash 抽帧,最多 8 帧。关闭压缩时仍会转 JPEG,但不缩放。主智能体只把适配器引用物化为本地路径,不在组装附件时预编码 JPEG。`max_quoted_fallback_images` 与 `quoted_message_parser` 限制引用消息和转发消息展开深度,避免无限抓取。对 `quoted_message_parser` 而言,`0` 是有效边界:深度限制会保留根层并停止子层递归,`max_forward_fetch=0` 会禁止递归调用 `get_forward_msg`。负数或无效值会回退为默认值;该设置不会全局禁止引用消息回退路径中的直接 `get_msg` 调用。 -## BTW 双循环原型 - -`btw` 为当前的双循环原型提供统一入口。所有消息先进入对话循环;启用规则分类后,包含代码、文件、命令、搜索、调研或 Claude Code、Codex、OpenCode、HAPI 等 coding-agent 意图的请求会转入工作循环。`/work <任务>` 是内置指令,不依赖分类器,会把后面的自由文本提交给工作循环;`/work` 与 `/work status` 查询最近一次任务状态。工作循环复用现有 Agent 与工具执行链;核心没有内置 Codex、CC 或其他专用执行器。源码构建的 Docker 镜像虽然预装了 `claude` 和 `codex` CLI,但它们只有通过工作循环的 Shell 工具或外部插件才能被调用。 - -- `btw.enabled`:总开关。关闭后,所有请求仍通过对话循环使用既有 Agent 路径。 -- `btw.classifier.enabled`:启用内置的确定性分类规则;关闭后不会自动转入工作循环。 -- `btw.conversation_loop.provider_id`:对话循环模型。留空时使用会话默认模型;填写后会优先于会话模型选择。 -- `btw.work_loop.enabled`:启用工作循环;`max_concurrent` 限制同一配置档可同时执行的已分类工作任务数。 -- `btw.work_loop.provider_id`:工作循环模型。可与对话循环使用不同 Provider;留空时使用会话默认模型。 -- `btw.work_loop.computer_use_runtime`:工作循环的电脑权限。`inherit` 使用原有 `provider_settings.computer_use_runtime`,也可显式设为 `none`、`local` 或 `sandbox`。 -- 工作循环不会在 IM 内提权:即使工作循环运行,高风险 `tool.*` 动作在 IM 仍按上游规则拒绝。权限隔离通过配置档的 `computer_use_runtime` 控制;对话循环硬性禁用这些工具,工作循环的运行时选择(`none`、`local`、`sandbox`)是唯一控制面。 -- `btw.work_session.max_age_seconds`:终态工作会话保留时间,默认 `3600` 秒;到期后会在下一次会话操作时清理。 -- `btw.plugin_routes`:在 **配置文件** 页为每个已启用的非系统插件选择“仅对话循环”“仅工作循环”或“两者”。未保存条目默认“仅工作循环”;选择“两者”会保存为显式覆盖。 -- `btw.mcp_routes`:为每个已启用 MCP 服务器做相同的循环选择。未保存条目也默认“仅工作循环”,因此 `mcp__codex__codex` 等执行型 MCP 不会自动进入对话循环。 -- `btw.skill_routes`:为每个已启用 Skill 做相同的循环选择。普通 Skill 未保存时默认注入两个循环;工作区 Skill 仍只会注入工作循环。 - -对话循环会强制禁用本地电脑、沙盒、浏览器和文件工具;这些能力只可能由工作循环获得。插件分配过滤插件注册给 LLM 的工具,MCP 分配过滤每个 MCP 服务器提供的全部工具,Skill 分配过滤注入的 Skill 提示。既有的子代理 handoff 也会应用相同的工具分配,且无法在对话循环重新获得电脑工具。Claude Code、Self Code、HAPI、Codex app-server、OpenCode 等外部插件注册的 LLM 工具因此默认只在工作循环可用。 - -插件的 Pipeline/Star 处理器和 `/hapi`、`/codexdev`、`/vibe`、`/oc` 等显式命令仍按插件既有优先级运行,不属于 LLM 工具路由。要让这类插件命令也采用后台工作会话,需要插件侧或后续的命令执行协议显式支持;不要把“插件工具仅工作循环”理解为整个插件都被迁移。 - -工作循环会先回复“工作任务已开始处理”,再由运行时后台任务执行;其结果从结果装饰阶段开始重放,包含回复内容安全检查、TTS/T2I 装饰和平台发送;入站阶段(唤醒、限流、入站内容安全)不会重新执行。后台工作使用与普通对话不同的会话锁,因此不会阻塞同一会话后续的聊天。工作会话是运行时内存状态,通过 `/work` 或 `/work status` 查询最近一次任务状态;重启或重建运行时后该状态不会保留。指令身份是 `builtin_commands:work`。 - -这些设置属于配置档。多个配置档时,应分别检查其 BTW 开关、并发数和插件工具分配。 - ## 子代理、语音与知识库 - `subagent_orchestrator.main_enable`:启用 handoff。 @@ -257,7 +233,7 @@ Dashboard 账户有稳定的 `account_id`,其 TOTP 密钥、恢复码哈希和 - Provider / Platform 使用三态 `proxy_mode`:`inherit` 跟随全局配置,`direct` 明确直连并忽略环境变量代理,`custom` 只使用本项 `proxy_url`。空字符串不再同时表示继承和直连。 - GitHub 镜像默认不提供。插件 `download_url` 和镜像前缀必须是公开 HTTPS origin,私网和非 HTTPS 会被拒绝。 - `platform_settings.segmented_reply` 仍是默认关闭的体验分段。Telegram / Discord / 企业微信的平台硬限制分段由发送层负责,二者不要混用。 -- `log_level`、`log_file_*`:控制台 Loguru sink、根 logger、未单独覆盖的插件 logger,以及轮转文件日志。`log_level` 会同步到终端输出,不只写文件。 +- `log_level`、`log_file_*`:控制台 Loguru sink、根 logger、未单独覆盖的插件 logger,以及轮转文件日志。`log_level` 会同步到终端输出,不只写文件。文件日志走同一脱敏出口:已识别的密钥字段、Bearer、URL 和绝对路径会在写入前替换。Cookie、私聊和自定义 secret 不保证被剥离;分享前仍需人工检查。 - `trace_enable`:Trace 采集总开关;`trace_log_*` 控制独立 Trace 文件。 - `temp_dir_max_size`:`data/temp` 上限(MiB),默认 `1024`;后台定期清理旧文件。 - `timezone`:IANA 时区名称,默认 `Asia/Shanghai`。 diff --git a/docs/zh/dev/btw-dual-loop.md b/docs/zh/dev/btw-dual-loop.md new file mode 100644 index 0000000000..02789cadcc --- /dev/null +++ b/docs/zh/dev/btw-dual-loop.md @@ -0,0 +1,105 @@ +--- +outline: deep +--- + +# BTW 双循环设计说明 + +本文记录 [PR #28](https://github.com/Xero-Team/AstrBot/pull/28) 的设计方向和功能拆分。该 PR 仅交付文档;下文中的循环、命令和配置是后续开发范围,不代表当前版本已经提供这些功能。 + +## 目标与职责 + +BTW 希望让用户在较长的工作任务执行期间继续对话,并能查询任务状态、接收结果。对话和工作复用现有 Agent 执行路径,各自承担不同职责。 + +| 循环 | 职责 | 预期能力范围 | +| -------- | -------------------------------------------------------------- | ---------------------------------------- | +| 对话循环 | 理解请求、澄清需求、完成自身能力范围内的请求,保持交互连续 | 当前请求实际可用的模型、工具与 Skill | +| 工作循环 | 承接显式提交或未来经对话循环转交的任务,管理执行状态并回送结果 | 配置分配给工作的能力,继续受现有授权约束 | + +工作循环不是新的权限级别,转交也不授予额外权限。能力按循环分配,决定模型可以看见什么;任务路由决定某次请求交给哪个循环处理。这两个问题分别推进。 + +## 当前决策 + +- PR #28 只保留这份设计说明,原型代码作为历史参考,功能通过总 Issue 和 Sub-issues 逐项讨论、实现和验收。 +- 后续实现从当前主干出发,复用现有消息流水线、Agent runner、工具目录和 Skill 快照;不直接搬回原型中已经过时的装配或授权逻辑。 +- 初期保持显式入口与默认关闭原则:通过 `/work ` 提交工作。原型规则分类器保留为对照方案,在独立实验 PR 中测试,不预先选为产品默认路由。 +- 期望的后续方向是:**路由判断并入对话循环;对话循环知道自身实际可用的能力,能完成的自行处理,需要工作能力的再转交工作循环。** +- 上述路由方向暂缓产品接入。**不同分类器分别提交独立 PR,从相同基线出发,按同一评估约定分开测试。** 根据结果再确定转交契约与采用方案;这些实验不阻塞其余能力的拆分。 + +原型参考为提交 [`33ee103a62937db3e930c89ba47a648b75cc7772`](https://github.com/Xero-Team/AstrBot/commit/33ee103a62937db3e930c89ba47a648b75cc7772)。该版本由 `ConversationLoop.process()` 在模型调用前执行规则分类或接收显式工作标记,并没有实现由对话模型根据自身能力作出转交判断。 + +## 功能拆分 + +以下 B1–B10 是拟议的功能子 Issue,R1–R3 分别对应独立的分类器实验 PR;编号仅用于本文讨论,并非 GitHub Issue 编号。每项同时包含相应配置、Dashboard 交互、测试与中英文说明,不另拆一组纯前端或纯后端任务。表中的验收要求尚未在本 PR 中实现或验证。 + +| 编号 | 子 Issue 范围 | 独立验收重点 | +| ---- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| B1 | 默认关闭的双循环接入与普通对话 | 关闭时保持当前 Agent 路径和能力;开启后普通请求进入对话循环;自动分类器实验不成为本项的前置条件。 | +| B2 | 显式工作任务入口 `/work ` | 使用正式内置命令身份接收完整任务文本;分类器关闭仍可提交;工作关闭时给出明确提示;遵守命令权限和会话 LLM 开关。 | +| B3 | 后台工作执行、并发与生命周期 | 先确认接收,再后台执行,期间可以继续对话;限制并发;排队、运行、完成、失败、取消状态可验证;关闭运行时会收回任务。 | +| B4 | 工作结果回送与事件资源释放 | 结果回到原请求,经过回复内容检查、装饰和发送;确认消息不提前结束 WebChat 请求;最终清理临时文件和事件注册。 | +| B5 | 工作状态查询与保留 | `/work` 与 `/work status` 查询当前配置、当前会话的最新任务;空状态、终态和过期行为明确;配置之间不串状态,界面说明群聊查询范围。 | +| B6 | 每个循环独立选择模型 | 对话与工作可选择不同模型,空值继承当前选择;选错或不可用时的行为明确;关闭 BTW 不改变普通模型选择。 | +| B7 | 每个循环的 Computer Use 边界 | 对话循环不挂载 Computer Use;工作循环可继承或选择 `none`、`local`、`sandbox`;handoff 保持相同限制,循环选择不改变权限。 | +| B8 | 插件 LLM 工具按循环分配 | 按插件配置对话、工作或两者;原型默认工作;主 Agent 与 handoff 一致。只影响 LLM 工具,不改变插件事件处理器或显式命令的执行归属。 | +| B9 | MCP 工具按服务器分配循环 | 同一 MCP 服务器的工具遵循统一分配;原型默认工作;配置保存、主 Agent 与 handoff 一致,沿用 MCP 连接和授权边界。 | +| B10 | Skill 按循环可见 | 普通 Skill 原型默认两者;工作区 Skill 保留工作循环与本地运行时边界;提示词、`read_skill` 和声明工具使用一致的筛选结果;读取 Skill 不要求 Shell 权限。 | +| R1 | 规则分类器实验 PR | 以原型关键词和确定性规则为对照,验证词边界、日常查询和能力变化时的误判;独立记录结果。 | +| R2 | 独立模型分类器实验 PR | 在对话执行前单独调用模型判断去向,测量分类效果及额外调用的时延、成本;不预设它是最终架构。 | +| R3 | 对话循环内能力感知实验 PR | 对话模型基于当前可用能力自行处理或转交,验证误转交、遗漏和上下文延续;这是当前倾向重点验证的方向。 | + +### 依赖与交付顺序 + +B1 定义启用边界。B2–B5 共同完成“提交 → 后台执行 → 回送结果 → 查询状态”的体验,不能把仅能确认接收、无法回送结果的中间状态作为可用功能交付。它们可以分别评审,首个可用版本需要完成这条链路。 + +B6、B7 明确每个循环的模型与运行环境。B8–B10 分别交付可用能力的分配;共享的目录筛选或配置控件随首个使用它的切片引入。每项验收同时覆盖 BTW 开启与关闭、主 Agent 与 handoff。 + +R1–R3 是平行方案,互不依赖,不串成前一个分类器改完再开发下一个的提交链。先约定共同基线和评估方法,各自实现、测试和评审;需要真实工作循环的集成评估,再共同更新到同一个基线。产品接入须等实验结论及工作执行、结果回送、能力分配契约明确后另行决定。 + +## 路由实验的边界 + +### 独立 PR 与共同评估约定 + +R1–R3 是首轮候选拆法,可在各子 Issue 中调整。每个 PR 只承载一种分类器实现及其测试,写明基线提交、样例版本、能力集合、模型与参数。共同样例和评估脚手架应先固定,避免每个实现选择不同的数据证明自己有效。 + +各 PR 分别执行相同的离线样例,随后在共同的可用双循环基线上执行相同的集成场景。确定性测试用于验证契约,模型试验另行记录重复运行的波动;两类结果分别报告。显式 `/work` 是所有方案的控制基线,不作为第四种分类器。 + +总 Issue 汇总各 PR 的结果、成本和取舍。完成比较前不把多个候选整包合入,也不先增加生产环境的多分类器切换框架。选型后的产品接入单独评审,未采用的实现保留为实验记录。 + +### 能力可见性 + +对话循环应看到本次请求实际解析出的能力,而不是所有已安装工具的名称。研究输入包括当前模型、配置与 Persona 筛选后的工具目录、Skill 快照、运行环境限制,以及工作循环可承接的范围。 + +实现时以当前的 `astrbot/core/tool_catalog.py`、主 Agent 的目录装配和 Skill 快照为事实来源,避免维护另一份容易漂移的能力清单。工具出现在目录或 Skill 中不等于获得执行授权;执行时仍由现有授权服务判断。 + +### 要回答的问题 + +- 同一个请求在能力配置不同的情况下,对话循环能否作出相应的处理或转交判断? +- 能否区分“需要工作能力”“缺少授权”“需要用户澄清”和“两边都无法完成”,避免把所有失败都转交? +- 转交发生在首次执行前,还是允许处理过程中发现能力缺口后转交?后一种方式如何避免重复执行已经产生副作用的步骤? +- 转交需要携带哪些上下文、已完成步骤和结果期望,才能让工作循环继续完成同一个任务? + +这些是实验问题,不在本文中预先固定新的工具接口、提示词格式或路由服务。 + +### 样例与指标 + +样例至少覆盖纯聊天、可用工具即可完成的查询、需要工作区或外部执行的任务、混合请求、需求不完整、工具离线、权限不足,以及两边都不能完成的请求。相同请求需要搭配不同能力集合,检验判断是否真的依赖能力。 + +记录误转交、漏转交、任务完成率、澄清与拒绝是否合适、额外时延、模型调用和 token 成本、重复转交与重复执行。先使用固定数据和模拟工具验证,不以线上默认开启作为实验方法。门槛应在评估前说明,实验结论可以是继续保持显式入口。 + +## 实现与验收约束 + +- **当前路径:** 复用 `AgentRequestSubStage`、当前工具目录与 Skill 快照。第三方 Agent runner 的模型、工具可控范围需单独确认,不宣称本地 runner 的能力隔离自动覆盖外部服务。 +- **授权:** 沿用当前配置范围、角色和入口规则。BTW 标记、路由结果或 Skill 声明都不能充当授权凭证;本设计不恢复原型曾讨论的 BTW 专用提权机制。 +- **请求身份:** WebChat 的确认、`run_started`、每次模型调用的 `agent_stats`、流式结果、最终结束与中断始终归属原 `message_id`;并发任务不能退化为会话级忙碌标志。 +- **生命周期:** 后台任务由运行时拥有;取消继续传播。验证排队期间取消、执行失败、投递失败、配置重载和关闭时的状态与清理;活跃任务不能按终态保留时间过期。 +- **配置:** 采用当前配置档保存路径和单一配置形状,默认关闭;不为原型旧字典格式增加兼容层。普通 Skill 默认两者与插件/MCP 默认工作的差异必须在界面中可见。 +- **验证:** 每项使用最接近的现有单测和 Dashboard 测试,并为其可观察行为补回归覆盖。后台集成必须验证真实调度器和 WebChat 协议,不能仅凭模拟 dispatcher 的成功路径宣称整链可用。 +- **文档与接口:** 功能落地时同步更新命令、配置及专题的中英文文档。只有确实改变 HTTP 契约时才同步 OpenAPI 和生成物;当前设计不要求新增 HTTP 接口。 + +## 总 Issue 与非目标 + +拟议总 Issue 为“BTW 双循环:功能拆分与分类器对比”,负责汇总 B1–B10 的进度、依赖和验收,以及 R1–R3 各自的实验 PR 和结果。分类器产品接入暂缓。待逐项讨论后创建 GitHub 总 Issue 与原生 Sub-issues;届时用真实链接替换本文的讨论编号。 + +本轮不包含工作任务持久化与断点续跑、跨设备工作调度、自动重试或回滚平台、多任务管理页面,以及生产环境默认开启自动路由。原型中的 `pyupgrade` 调整属于独立仓库维护,也不属于本文的 BTW 功能范围。 + +当前运行行为仍以[项目架构](./architecture.md)、[Computer Use](../use/computer.md)与 [Skills](../use/skills.md) 为准。本文中的设计方向和验收条件需要在后续 Issue 中达成共识,不替代实现评审。 diff --git a/docs/zh/use/command.md b/docs/zh/use/command.md index dc95e2141e..5f04dd46b2 100644 --- a/docs/zh/use/command.md +++ b/docs/zh/use/command.md @@ -82,8 +82,6 @@ Orbit 不执行变量、命令、算术或波浪号展开,也不执行 glob、 ### 运行任务 - `/task stop`:停止当前会话中正在运行的 Agent 或第三方 Agent Runner 任务,不删除历史。 -- `/work <任务>`:把后面的自由文本提交给 BTW 工作循环。不依赖 `/chat` 前缀,也不依赖任务分类器。需要 `session.read`,且配置档已启用 `btw.enabled` 与 `btw.work_loop.enabled`。指令身份是 `builtin_commands:work`。 -- `/work` 或 `/work status`:查询本会话最近一次工作任务状态。`status` 只在剩余文本整段匹配时视为查询(大小写不敏感);`/work status 重构` 会作为任务提交。需要 `session.read`。状态只存在于运行时内存,重启后清空。 ### Provider 与模型 diff --git a/docs/zh/use/computer.md b/docs/zh/use/computer.md index 2890467a41..5978f0b49d 100644 --- a/docs/zh/use/computer.md +++ b/docs/zh/use/computer.md @@ -73,7 +73,7 @@ data/workspaces/{normalized_umo}/notes/todo.txt - `tool.file_write` - `tool.browser_control` -`tool.file_read` 对当前会话的 member 及以上开放,但仍受路径约束。`tool.local_exec`、`tool.python_exec`、`tool.file_write`、`tool.browser_control` 和 `tool.computer_use` 是高风险动作:已认证 Dashboard 驱动的 WebChat 仅可在当前 session/config 内,经 WebChat 一次性 step-up 后使用;全局控制面仍 Dashboard-only,匿名 WebChat、IM、插件、Agent 和 API Key 一律不会继承 Dashboard 角色。IM 一律不继承高风险动作:BTW 工作循环不提供任何提权通道,无论配置如何,IM 内的高风险 `tool.*` 动作都保持 Dashboard-only。沙箱、路径和 Persona/工具声明限制仍然有效。 +`tool.file_read` 对当前会话的 member 及以上开放,但仍受路径约束。`tool.local_exec`、`tool.python_exec`、`tool.file_write`、`tool.browser_control` 和 `tool.computer_use` 是高风险动作:已认证 Dashboard 驱动的 WebChat 仅可在当前 session/config 内,经 WebChat 一次性 step-up 后使用;全局控制面仍 Dashboard-only,匿名 WebChat、IM、插件、Agent 和 API Key 一律不会继承 Dashboard 角色。沙箱、路径和 Persona/工具声明限制仍然有效。 `local` 模式下,普通会话成员可以读取: @@ -83,7 +83,7 @@ data/workspaces/{normalized_umo}/notes/todo.txt - AstrBot 的临时目录 - 系统临时目录中的 `.astrbot` -写入和编辑仍限制在当前会话 workspace 和临时目录。请通过 Dashboard [权限页面](/use/webui#账户与权限)授予匹配动作的绑定;`/admin grant` 只授予当前会话 `session_admin`,不能把 IM 用户变成全局 operator。开发模型见[项目架构](/dev/architecture#统一授权系统)。 +写入和编辑仍限制在当前会话 workspace 和临时目录。请通过 Dashboard [权限页面](/use/authorization)授予匹配动作的绑定;`/admin grant` 只授予当前会话 `session_admin`,不能把 IM 用户变成全局 operator。开发模型见[项目架构](/dev/architecture#统一授权系统)。 ## Sandbox 模式 diff --git a/tests/unit/test_agent_internal_process.py b/tests/unit/test_agent_internal_process.py index ea55faa669..73d4038015 100644 --- a/tests/unit/test_agent_internal_process.py +++ b/tests/unit/test_agent_internal_process.py @@ -1,7 +1,7 @@ +from __future__ import annotations + import pytest -from astrbot.core.agent.tool import ToolSet -from astrbot.core.astr_main_agent import MainAgentBuildConfig from astrbot.core.message.components import Json from tests.unit.agent_sub_stage_support import * # noqa: F403 @@ -1194,185 +1194,3 @@ def fail_on_second_create_task(coro, *, name=None): == "Error occurred during AI execution." ) event.stop_typing.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_internal_builder_applies_model_and_permission_per_btw_loop( - monkeypatch, -): - stage = internal.InternalAgentSubStage.__new__(internal.InternalAgentSubStage) - stage.ctx = _pipeline_context(_internal_plugin_context()) - stage.btw_enabled = True - stage.main_agent_cfg = MainAgentBuildConfig( - tool_call_timeout=60, - computer_use_runtime="local", - provider_settings={"computer_use_runtime": "local"}, - conversation_provider_id="conversation-model", - work_provider_id="work-model", - work_computer_use_runtime="sandbox", - btw_mcp_routes=[{"server_name": "workspace", "loop": "work"}], - btw_skill_routes=[{"skill_name": "workspace-edit", "loop": "work"}], - ) - build_result = SimpleNamespace( - provider=SimpleNamespace(provider_config={"api_base": ""}), - ) - build_main_agent = AsyncMock(return_value=build_result) - monkeypatch.setattr(internal, "build_main_agent", build_main_agent) - - conversation_event = FakeEvent() - assert ( - await stage._build_checked_agent_runner( - conversation_event, - streaming_response=True, - ) - is build_result - ) - conversation_config = build_main_agent.await_args.kwargs["config"] - assert conversation_config.loop_mode == "conversation" - assert conversation_config.provider_id_override == "conversation-model" - assert conversation_config.computer_use_runtime == "none" - assert conversation_config.btw_mcp_routes == [ - {"server_name": "workspace", "loop": "work"} - ] - assert conversation_config.btw_skill_routes == [ - {"skill_name": "workspace-edit", "loop": "work"} - ] - - work_event = FakeEvent(extras={"btw_loop": "work"}) - assert ( - await stage._build_checked_agent_runner( - work_event, - streaming_response=False, - ) - is build_result - ) - work_config = build_main_agent.await_args.kwargs["config"] - assert work_config.loop_mode == "work" - assert work_config.provider_id_override == "work-model" - assert work_config.computer_use_runtime == "sandbox" - assert work_config.provider_settings["computer_use_runtime"] == "sandbox" - - -@pytest.mark.asyncio -async def test_internal_builder_btw_disabled_matches_master_path(monkeypatch): - """With BTW disabled the runner build must be upstream-master identical.""" - stage = internal.InternalAgentSubStage.__new__(internal.InternalAgentSubStage) - stage.ctx = _pipeline_context(_internal_plugin_context()) - stage.btw_enabled = False - stage.main_agent_cfg = MainAgentBuildConfig( - tool_call_timeout=60, - computer_use_runtime="local", - provider_settings={"computer_use_runtime": "local"}, - conversation_provider_id="conversation-model", - work_provider_id="work-model", - work_computer_use_runtime="sandbox", - ) - build_result = SimpleNamespace( - provider=SimpleNamespace(provider_config={"api_base": ""}), - ) - build_main_agent = AsyncMock(return_value=build_result) - monkeypatch.setattr(internal, "build_main_agent", build_main_agent) - - event = FakeEvent(extras={"btw_loop": "work"}) - auth_context = SimpleNamespace(metadata={}) - event.auth_context = auth_context - - assert ( - await stage._build_checked_agent_runner(event, streaming_response=False) - is build_result - ) - config = build_main_agent.await_args.kwargs["config"] - assert config.btw_enabled is False - # No loop_mode override is written at all: the profile default passes through. - assert config.loop_mode == "conversation" - assert config.provider_id_override == "" - assert config.computer_use_runtime == "local" - assert config.provider_settings["computer_use_runtime"] == "local" - # No elevation metadata is stamped on the auth context. - assert auth_context.metadata == {} - - -@pytest.mark.asyncio -async def test_disabled_btw_keeps_local_tools_and_workspace_skills(monkeypatch): - """btw_enabled=False + computer_use_runtime=local keeps master behavior. - - The disabled path must still apply local environment tools and still - inject workspace Skills — an operator who never touched BTW must not - lose host capabilities just because this feature ships. - """ - import astrbot.core.astr_main_agent as ama - - req = ProviderRequest(prompt="hello") - plugin_context = SimpleNamespace( - catalogs=SimpleNamespace( - tools=SimpleNamespace(), - plugins=SimpleNamespace(get_by_module=lambda _p: None), - ), - computer_runtime=SimpleNamespace(get_session_booter=lambda _s: None), - get_config=lambda **_kw: {"timezone": "UTC"}, - ) - config = MainAgentBuildConfig( - tool_call_timeout=60, - btw_enabled=False, - loop_mode="conversation", - computer_use_runtime="local", - provider_settings={"computer_use_runtime": "local"}, - timezone="UTC", - ) - - applied_local = MagicMock() - monkeypatch.setattr(ama, "_apply_local_env_tools", applied_local) - applied_sandbox = MagicMock() - monkeypatch.setattr(ama, "_apply_sandbox_tools", applied_sandbox) - - ok = await ama._prepare_request_for_agent( - SimpleNamespace( - message_obj=SimpleNamespace(message=[]), - unified_msg_origin="webchat:FriendMessage:u", - plugins_name=None, - get_extra=lambda _k, default=None: default, - get_platform_id=lambda: "webchat", - ), - req, - plugin_context, - config, - provider=None, - ) - assert ok - applied_local.assert_called_once() - applied_sandbox.assert_not_called() - - # Workspace Skills also stay available when BTW is off. - skill_manager = SimpleNamespace( - list_workspace_skills=MagicMock(return_value=[]), - list_skills=MagicMock(return_value=[]), - ) - plugin_context2 = SimpleNamespace( - skill_manager=skill_manager, - catalogs=SimpleNamespace( - builtin_skills=None, - plugins=SimpleNamespace(get_by_module=lambda _p: None, all=lambda: []), - ), - persona_manager=SimpleNamespace( - resolve_selected_persona=AsyncMock(return_value=("", None, None, False)), - ), - get_llm_tool_manager=lambda: SimpleNamespace( - get_full_tool_set=lambda: ToolSet() - ), - get_config=lambda **_kw: {"timezone": "UTC"}, - subagent_orchestrator=None, - ) - await ama._ensure_persona_and_skills( - ProviderRequest(prompt="x", conversation=SimpleNamespace(persona_id="")), - {"computer_use_runtime": "local"}, - plugin_context2, - SimpleNamespace( - unified_msg_origin="webchat:FriendMessage:u", - get_platform_name=lambda: "webchat", - set_extra=lambda *_a, **_k: None, - get_extra=lambda _k, default=None: default, - ), - loop_mode="conversation", - btw_enabled=False, - ) - skill_manager.list_workspace_skills.assert_called_once() diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index 1ea6108c68..f511d50375 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -5,39 +5,30 @@ import mcp import pytest -from mcp.types import Tool from astrbot.core.agent.agent import Agent from astrbot.core.agent.handoff import HandoffTool -from astrbot.core.agent.mcp_client import MCPTool, MCPToolNameAllocator from astrbot.core.agent.run_context import ContextWrapper -from astrbot.core.agent.tool import FunctionTool, ToolSet +from astrbot.core.agent.tool import FunctionTool from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor, call_local_llm_tool from astrbot.core.auth.models import AuthContext, Resource, Subject from astrbot.core.message.components import Image -from astrbot.core.tools.computer_tools import FileReadTool from astrbot.core.tools.function_tool_manager import ( FunctionToolManager, ) class _DummyEvent: - def __init__( - self, - message_components: list[object] | None = None, - *, - extras: dict | None = None, - ) -> None: + def __init__(self, message_components: list[object] | None = None) -> None: self.unified_msg_origin = "webchat:FriendMessage:webchat!user!session" self.message_obj = SimpleNamespace(message=message_components or []) self.role = "member" - self._extras = extras or {} - def get_extra(self, key: str, default=None): - return self._extras.get(key, default) + def get_extra(self, _key: str, default=None): + return default - def set_extra(self, key: str, value) -> None: - self._extras[key] = value + def set_extra(self, _key: str, _value) -> None: + return None class _DummyTool: @@ -242,99 +233,6 @@ def test_build_handoff_toolset_keeps_declared_tools(runtime): ) -def test_handoff_toolset_defaults_plugin_mcp_and_computer_tools_to_work(): - mcp_tool = MCPTool( - Tool( - name="workspace_mcp", - description="workspace MCP", - inputSchema={"type": "object", "properties": {}}, - ), - AsyncMock(), - "workspace-server", - ) - safe_tool = FunctionTool( - name="safe", - description="safe", - parameters={"type": "object", "properties": {}}, - ) - plugin_tool = FunctionTool( - name="coding_agent", - description="coding agent", - parameters={"type": "object", "properties": {}}, - handler_module_path="plugins.coding.main", - ) - event = _DummyEvent(extras={"btw_loop": "conversation"}) - plugin = SimpleNamespace(root_dir_name="coding", name="coding") - context = SimpleNamespace( - catalogs=SimpleNamespace( - plugins=SimpleNamespace( - get_by_module=lambda module_path: ( - plugin if module_path == "plugins.coding.main" else None - ) - ) - ) - ) - - filtered = FunctionToolExecutor._filter_handoff_toolset_for_btw( - ToolSet([mcp_tool, FileReadTool(), plugin_tool, safe_tool]), - ctx=context, - cfg={"btw": {"enabled": True}}, - event=event, - ) - - assert filtered.names() == ["safe"] - - -def test_handoff_toolset_honors_explicit_both_routes(): - mcp_tool = MCPTool( - Tool( - name="workspace_mcp", - description="workspace MCP", - inputSchema={"type": "object", "properties": {}}, - ), - AsyncMock(), - "workspace-server", - ) - plugin_tool = FunctionTool( - name="coding_agent", - description="coding agent", - parameters={"type": "object", "properties": {}}, - handler_module_path="plugins.coding.main", - ) - event = _DummyEvent(extras={"btw_loop": "conversation"}) - plugin = SimpleNamespace(root_dir_name="coding", name="coding") - context = SimpleNamespace( - catalogs=SimpleNamespace( - plugins=SimpleNamespace( - get_by_module=lambda module_path: ( - plugin if module_path == "plugins.coding.main" else None - ) - ) - ) - ) - - filtered = FunctionToolExecutor._filter_handoff_toolset_for_btw( - ToolSet([mcp_tool, plugin_tool]), - ctx=context, - cfg={ - "btw": { - "mcp_routes": [ - {"server_name": "workspace-server", "loop": "both"}, - ], - "plugin_routes": [ - {"plugin_id": "coding", "loop": "both"}, - ], - } - }, - event=event, - ) - - assert filtered.names() == [ - MCPToolNameAllocator().allocate("workspace-server", "workspace_mcp"), - "coding_agent", - ] - - @pytest.mark.asyncio async def test_collect_handoff_image_urls_normalizes_filters_and_appends_event_image( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 8ae01ff3b6..599351a77a 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -7,11 +7,10 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from mcp.types import Tool from astrbot.core import astr_main_agent as ama from astrbot.core.agent.llm_types import ProviderRequest -from astrbot.core.agent.mcp_client import MCPTool, MCPToolNameAllocator +from astrbot.core.agent.mcp_client import MCPTool from astrbot.core.agent.message import Message, TextPart, dump_messages_with_checkpoints from astrbot.core.agent.request_preparation import prepare_provider_request from astrbot.core.agent.tool import FunctionTool, ToolSet @@ -117,223 +116,6 @@ def test_provider_supports_modality_requires_explicit_list(): assert not ama._provider_supports_modality(provider, "image") -def test_filter_plugin_tools_for_loop_keeps_only_assigned_plugin_tools(): - plugin_tool = FunctionTool( - name="plugin_tool", - description="plugin tool", - parameters={"type": "object", "properties": {}}, - handler_module_path="plugins.example.main", - ) - builtin_tool = FunctionTool( - name="builtin_tool", - description="builtin tool", - parameters={"type": "object", "properties": {}}, - ) - req = ProviderRequest(prompt="test", func_tool=ToolSet([plugin_tool, builtin_tool])) - plugin = SimpleNamespace(root_dir_name="example", name="example") - context = SimpleNamespace( - catalogs=SimpleNamespace( - plugins=SimpleNamespace( - get_by_module=lambda module_path: ( - plugin if module_path == "plugins.example.main" else None - ) - ) - ) - ) - config = ama.MainAgentBuildConfig( - btw_enabled=True, - tool_call_timeout=60, - loop_mode="conversation", - btw_plugin_routes=[{"plugin_id": "example", "loop": "work"}], - ) - - ama._filter_plugin_tools_for_loop(req, context, config) - - assert req.func_tool is not None - assert req.func_tool.names() == ["builtin_tool"] - - -def test_filter_plugin_tools_for_loop_defaults_unassigned_tools_to_work(): - plugin_tool = FunctionTool( - name="plugin_tool", - description="plugin tool", - parameters={"type": "object", "properties": {}}, - handler_module_path="plugins.example.main", - ) - req = ProviderRequest(prompt="test", func_tool=ToolSet([plugin_tool])) - context = SimpleNamespace( - catalogs=SimpleNamespace( - plugins=SimpleNamespace( - get_by_module=lambda module_path: ( - SimpleNamespace(root_dir_name="example", name="example") - if module_path == "plugins.example.main" - else None - ) - ) - ) - ) - config = ama.MainAgentBuildConfig( - btw_enabled=True, tool_call_timeout=60, loop_mode="conversation" - ) - - ama._filter_plugin_tools_for_loop(req, context, config) - - assert req.func_tool is not None - assert req.func_tool.names() == [] - - work_req = ProviderRequest(prompt="test", func_tool=ToolSet([plugin_tool])) - work_config = ama.MainAgentBuildConfig( - btw_enabled=True, tool_call_timeout=60, loop_mode="work" - ) - - ama._filter_plugin_tools_for_loop(work_req, context, work_config) - - assert work_req.func_tool is not None - assert work_req.func_tool.names() == ["plugin_tool"] - - -def test_filter_plugin_tools_for_loop_honors_explicit_both_assignment(): - plugin_tool = FunctionTool( - name="plugin_tool", - description="plugin tool", - parameters={"type": "object", "properties": {}}, - handler_module_path="plugins.example.main", - ) - req = ProviderRequest(prompt="test", func_tool=ToolSet([plugin_tool])) - context = SimpleNamespace( - catalogs=SimpleNamespace( - plugins=SimpleNamespace( - get_by_module=lambda module_path: ( - SimpleNamespace(root_dir_name="example", name="example") - if module_path == "plugins.example.main" - else None - ) - ) - ) - ) - config = ama.MainAgentBuildConfig( - tool_call_timeout=60, - btw_enabled=True, - loop_mode="conversation", - btw_plugin_routes=[{"plugin_id": "example", "loop": "both"}], - ) - - ama._filter_plugin_tools_for_loop(req, context, config) - - assert req.func_tool is not None - assert req.func_tool.names() == ["plugin_tool"] - - -def test_route_filter_uses_capability_default_for_malformed_assignment(): - routes = [{"plugin_id": "coding", "loop": "unexpected"}] - - assert not ama._route_is_available_in_loop( - routes, - route_key="plugin_id", - route_id="coding", - loop_mode="conversation", - default_loop="work", - ) - assert ama._route_is_available_in_loop( - routes, - route_key="plugin_id", - route_id="coding", - loop_mode="work", - default_loop="work", - ) - - -def test_filter_mcp_tools_for_loop_keeps_only_assigned_servers(): - input_schema = {"type": "object", "properties": {}} - conversation_tool = MCPTool( - Tool(name="weather", description="weather", inputSchema=input_schema), - MagicMock(), - "weather-server", - ) - work_tool = MCPTool( - Tool(name="workspace", description="workspace", inputSchema=input_schema), - MagicMock(), - "workspace-server", - ) - req = ProviderRequest( - prompt="test", - func_tool=ToolSet([conversation_tool, work_tool]), - ) - config = ama.MainAgentBuildConfig( - btw_enabled=True, - tool_call_timeout=60, - loop_mode="conversation", - btw_mcp_routes=[ - {"server_name": "weather-server", "loop": "conversation"}, - {"server_name": "workspace-server", "loop": "work"}, - ], - ) - - ama._filter_mcp_tools_for_loop(req, config) - - assert req.func_tool is not None - assert req.func_tool.names() == [ - MCPToolNameAllocator().allocate("weather-server", "weather") - ] - - -def test_filter_mcp_tools_for_loop_defaults_unassigned_servers_to_work(): - input_schema = {"type": "object", "properties": {}} - work_tool = MCPTool( - Tool(name="workspace", description="workspace", inputSchema=input_schema), - MagicMock(), - "workspace-server", - ) - conversation_req = ProviderRequest( - prompt="test", - func_tool=ToolSet([work_tool]), - ) - conversation_config = ama.MainAgentBuildConfig( - btw_enabled=True, - tool_call_timeout=60, - loop_mode="conversation", - ) - - ama._filter_mcp_tools_for_loop(conversation_req, conversation_config) - - assert conversation_req.func_tool is not None - assert conversation_req.func_tool.names() == [] - - work_req = ProviderRequest(prompt="test", func_tool=ToolSet([work_tool])) - work_config = ama.MainAgentBuildConfig( - btw_enabled=True, tool_call_timeout=60, loop_mode="work" - ) - - ama._filter_mcp_tools_for_loop(work_req, work_config) - - assert work_req.func_tool is not None - assert work_req.func_tool.names() == [ - MCPToolNameAllocator().allocate("workspace-server", "workspace") - ] - - -def test_filter_skills_for_loop_keeps_only_assigned_skills(): - skills = [ - SkillInfo( - name="chat-search", description="", path="chat/SKILL.md", active=True - ), - SkillInfo( - name="workspace-edit", - description="", - path="workspace/SKILL.md", - active=True, - ), - ] - - conversation_skills = ama._filter_skills_for_loop( - skills, - [{"skill_name": "workspace-edit", "loop": "work"}], - "conversation", - ) - - assert [skill.name for skill in conversation_skills] == ["chat-search"] - - @pytest.mark.asyncio async def test_prepare_event_attachments_is_idempotent(mock_event, mock_context): req = ProviderRequest() @@ -755,21 +537,6 @@ def test_select_provider_by_id(self, mock_event, mock_context, mock_provider): assert result == mock_provider mock_context.get_provider_by_id.assert_called_once_with("test-provider") - def test_select_provider_prefers_loop_model_override( - self, mock_event, mock_context, mock_provider - ): - mock_event.get_extra.return_value = "session-provider" - mock_context.get_provider_by_id.return_value = mock_provider - - result = ama._select_provider( - mock_event, - mock_context, - provider_id_override="work-provider", - ) - - assert result == mock_provider - mock_context.get_provider_by_id.assert_called_once_with("work-provider") - def test_select_provider_not_found(self, mock_event, mock_context): """Test selecting provider when ID is not found.""" module = ama @@ -1516,11 +1283,7 @@ async def test_ensure_skills_includes_workspace_skills( runtime_config = {"computer_use_runtime": "local"} await module._ensure_persona_and_skills( - req, - runtime_config, - mock_context, - mock_event, - loop_mode="work", + req, runtime_config, mock_context, mock_event ) assert "**workspace-skill**" in req.system_prompt @@ -1737,7 +1500,6 @@ async def test_persona_empty_tools_keeps_local_runtime_builtin_tools( mock_event.platform_meta.support_proactive_message = False config = module.MainAgentBuildConfig( tool_call_timeout=60, - loop_mode="work", computer_use_runtime="local", add_cron_tools=False, ) @@ -1770,53 +1532,6 @@ async def test_persona_empty_tools_keeps_local_runtime_builtin_tools( if result.reset_coro: result.reset_coro.close() - def test_conversation_loop_filters_computer_and_filesystem_tools(self): - req = ProviderRequest( - prompt="hello", - func_tool=ToolSet( - [ - ama.ExecuteShellTool(), - ama.FileReadTool(), - FunctionTool( - name="safe_tool", - description="safe", - parameters={"type": "object", "properties": {}}, - ), - ] - ), - ) - config = ama.MainAgentBuildConfig( - tool_call_timeout=60, - loop_mode="conversation", - computer_use_runtime="local", - btw_enabled=True, - ) - - ama._filter_privileged_tools_for_conversation(req, config) - - assert req.func_tool is not None - assert req.func_tool.names() == ["safe_tool"] - - def test_conversation_loop_hard_isolation_disabled_with_btw_off(self): - """With BTW disabled the Agent path matches master: no tool stripping.""" - req = ProviderRequest( - prompt="hello", - func_tool=ToolSet([ama.ExecuteShellTool(), ama.FileReadTool()]), - ) - config = ama.MainAgentBuildConfig( - tool_call_timeout=60, - loop_mode="conversation", - computer_use_runtime="local", - ) - - ama._filter_privileged_tools_for_conversation(req, config) - - assert req.func_tool is not None - assert req.func_tool.names() == [ - "astrbot_execute_shell", - "astrbot_file_read_tool", - ] - @pytest.mark.asyncio async def test_omitted_runtime_does_not_expose_local_computer_tools( self, mock_event, mock_context, mock_provider diff --git a/tests/unit/test_authorization_service.py b/tests/unit/test_authorization_service.py index 1c47d29bd4..daeb0024d2 100644 --- a/tests/unit/test_authorization_service.py +++ b/tests/unit/test_authorization_service.py @@ -1624,68 +1624,6 @@ async def test_high_risk_allow_fails_closed_when_audit_queue_is_full(authorizati assert decision.reason == "audit_unavailable" -@pytest.mark.asyncio -async def test_im_subjects_cannot_bypass_step_up_for_high_risk_tools(authorization): - """IM operators are always denied high-risk tool actions as dashboard-only. - - IM has no step-up path: high-risk ``tool.*`` actions stay Dashboard-only - regardless of any work-loop metadata an event may carry. - """ - subject = Subject.im( - platform_instance="napcat", bot_account_id="bot", sender_id="42" - ) - await authorization.grant_binding( - actor=Subject.system("test"), - subject_id=subject.id, - role=Role.INSTANCE_OPERATOR, - scope_type="instance", - scope_id="default", - config_id="default", - enforce_actor=False, - ) - shell_resource = Resource.named( - "tool", "astrbot_execute_shell", config_id="default" - ) - # Like every real IM event (see waking_check/stage.py), the auth context - # is bound to its inbound session, so the upstream ``origin_session`` - # required-context gate is satisfied before the step-up branch. - session_resource = Resource.session("default", "napcat:FriendMessage:napcat!bot!42") - - def _btw_context(**metadata) -> AuthContext: - return AuthContext( - subject=subject, - source="im", - config_id="default", - authenticated=subject.authenticated, - origin_session_resource_id=session_resource.id, - metadata=metadata, - ) - - # Plain IM operator context. - denied = await authorization.authorize( - subject, - "tool.local_exec", - shell_resource, - _btw_context(), - ) - assert not denied.allowed - assert denied.reason == "high_risk_dashboard_only" - - # Stale work-loop elevation metadata (from a previous release) must not - # lift the deny. - with_metadata = await authorization.authorize( - subject, - "tool.local_exec", - shell_resource, - _btw_context( - btw_work_elevation=True, - btw_elevated_actions=("tool.local_exec",), - ), - ) - assert not with_metadata.allowed - assert with_metadata.reason == "high_risk_dashboard_only" - - @pytest.mark.asyncio async def test_binding_mutations_write_audit_records(authorization): owner = Subject.im(platform_instance="napcat", bot_account_id="bot", sender_id="42") diff --git a/tests/unit/test_btw.py b/tests/unit/test_btw.py deleted file mode 100644 index e357e37bbb..0000000000 --- a/tests/unit/test_btw.py +++ /dev/null @@ -1,253 +0,0 @@ -import asyncio -from datetime import UTC, datetime, timedelta -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest - -from astrbot.core.agent.btw import ( - TaskClassifier, - TaskType, - WorkLoop, - WorkSessionManager, - WorkSessionStatus, - is_work_loop_enabled, -) - - -class FakeEvent: - def __init__(self, message: str) -> None: - self.unified_msg_origin = "umo-1" - self.message_str = message - self.extras = {} - self.result = None - - def set_extra(self, key, value) -> None: - self.extras[key] = value - - def get_extra(self, key): - return self.extras.get(key) - - def set_result(self, value) -> None: - self.result = value - - -class FailingExecutor: - async def process(self, event): - del event - raise RuntimeError("provider token leaked") - yield - - -class BlockingExecutor: - def __init__(self) -> None: - self.started = asyncio.Event() - self.release = asyncio.Event() - - async def process(self, event): - del event - self.started.set() - await self.release.wait() - yield "done" - - -@pytest.mark.asyncio -async def test_task_classifier_selects_work_for_keywords_and_conversation_otherwise(): - # Keyword heuristics are opt-in: nothing is enabled by default. - disabled = TaskClassifier( - {"btw": {"enabled": True, "work_loop": {"enabled": True}}} - ) - assert ( - await disabled.classify(SimpleNamespace(message_str="帮我修改代码")) - is TaskType.CONVERSATION - ) - assert ( - await disabled.classify(SimpleNamespace(message_str="continue with Codex")) - is TaskType.CONVERSATION - ) - - classifier = TaskClassifier( - { - "btw": { - "enabled": True, - "classifier": {"enabled": True}, - "work_loop": {"enabled": True}, - } - } - ) - - assert ( - await classifier.classify(SimpleNamespace(message_str="你好")) - is TaskType.CONVERSATION - ) - assert ( - await classifier.classify(SimpleNamespace(message_str="帮我修改代码")) - is TaskType.WORK - ) - assert ( - await classifier.classify( - SimpleNamespace(message_str="让 Claude Code 处理这个仓库") - ) - is TaskType.WORK - ) - assert ( - await classifier.classify(SimpleNamespace(message_str="continue with Codex")) - is TaskType.WORK - ) - - -@pytest.mark.asyncio -async def test_task_classifier_defaults_exclude_everyday_queries_and_honor_word_boundaries(): - classifier = TaskClassifier( - { - "btw": { - "enabled": True, - "classifier": {"enabled": True}, - "work_loop": {"enabled": True}, - } - } - ) - - # Broad everyday keywords (search/搜索/查询/research) are not in the - # default set — they classify ordinary questions as conversation. - assert ( - await classifier.classify( - SimpleNamespace(message_str="search for a restaurant") - ) - is TaskType.CONVERSATION - ) - assert ( - await classifier.classify( - SimpleNamespace(message_str="what is the research paper about") - ) - is TaskType.CONVERSATION - ) - assert ( - await classifier.classify(SimpleNamespace(message_str="帮我搜索一下附近餐厅")) - is TaskType.CONVERSATION - ) - # Word boundaries still hold for the keywords that remain: a keyword - # never fires inside another word. - assert ( - await classifier.classify( - SimpleNamespace(message_str="search refactor helper in the codebase") - ) - is TaskType.WORK - ) - - -@pytest.mark.asyncio -async def test_task_classifier_respects_disabled_work_loop(): - classifier = TaskClassifier( - {"btw": {"enabled": True, "work_loop": {"enabled": False}}} - ) - - assert ( - await classifier.classify(SimpleNamespace(message_str="帮我修改代码")) - is TaskType.CONVERSATION - ) - - -def test_is_work_loop_enabled_requires_both_switches(): - assert is_work_loop_enabled(None) is False - assert is_work_loop_enabled("btw") is False - assert is_work_loop_enabled({}) is False - assert is_work_loop_enabled({"btw": {"enabled": True}}) is False - assert is_work_loop_enabled({"btw": {"enabled": True, "work_loop": True}}) is False - assert ( - is_work_loop_enabled( - {"btw": {"enabled": True, "work_loop": {"enabled": False}}} - ) - is False - ) - assert ( - is_work_loop_enabled( - {"btw": {"enabled": False, "work_loop": {"enabled": True}}} - ) - is False - ) - assert ( - is_work_loop_enabled({"btw": {"enabled": True, "work_loop": {"enabled": True}}}) - is True - ) - - -@pytest.mark.asyncio -async def test_task_classifier_does_not_treat_work_command_text_as_work(): - classifier = TaskClassifier( - { - "btw": { - "enabled": True, - "classifier": {"enabled": False}, - "work_loop": {"enabled": True}, - } - } - ) - - assert ( - await classifier.classify(SimpleNamespace(message_str="/work 重构项目")) - is TaskType.CONVERSATION - ) - assert ( - await classifier.classify(SimpleNamespace(message_str="work 重构项目")) - is TaskType.CONVERSATION - ) - - -@pytest.mark.asyncio -async def test_work_loop_marks_failures_without_exposing_executor_error(): - sessions = WorkSessionManager() - work_loop = WorkLoop(FailingExecutor(), sessions) - event = FakeEvent("执行命令") - - with pytest.raises(RuntimeError, match="provider token leaked"): - _ = [item async for item in work_loop.process(event)] - - session = await sessions.get_for_origin(event.unified_msg_origin) - assert session is not None - assert session.status is WorkSessionStatus.FAILED - - -@pytest.mark.asyncio -async def test_work_session_manager_expires_terminal_sessions(): - sessions = WorkSessionManager(max_age_seconds=60) - session = await sessions.create("umo-1", "执行命令") - await sessions.update_status(session.id, WorkSessionStatus.COMPLETED) - session.updated_at = datetime.now(UTC) - timedelta(seconds=61) - - assert await sessions.get_by_id(session.id) is None - assert await sessions.get_for_origin(session.origin) is None - - -@pytest.mark.asyncio -async def test_work_loop_acknowledges_then_runs_in_background(): - executor = BlockingExecutor() - sessions = WorkSessionManager() - work_loop = WorkLoop(executor, sessions) - background_tasks: set[asyncio.Task] = set() - result_dispatcher = AsyncMock() - event_finalizer = AsyncMock() - work_loop.configure_detached_execution( - background_tasks=background_tasks, - result_dispatcher=result_dispatcher, - event_finalizer=event_finalizer, - ) - event = FakeEvent("执行命令") - - output = [item async for item in work_loop.submit(event)] - - assert output == [None] - assert event.result.get_plain_text() == "🔧 工作任务已开始处理。" - assert len(background_tasks) == 1 - [task] = background_tasks - await asyncio.wait_for(executor.started.wait(), timeout=1) - session = await sessions.get_for_origin(event.unified_msg_origin) - assert session is not None - assert session.status is WorkSessionStatus.RUNNING - - executor.release.set() - await asyncio.wait_for(task, timeout=5) - - assert session.status is WorkSessionStatus.COMPLETED - result_dispatcher.assert_awaited_once_with(event) - event_finalizer.assert_awaited_once_with(event) diff --git a/tests/unit/test_builtin_command_extensions.py b/tests/unit/test_builtin_command_extensions.py index 380f2560b6..d7bf296e66 100644 --- a/tests/unit/test_builtin_command_extensions.py +++ b/tests/unit/test_builtin_command_extensions.py @@ -20,7 +20,6 @@ CommandEngine, CommandError, CommandErrorCode, - CommandResolutionKind, build_command_catalog, ) from astrbot.core.command.schema import compile_command_schema @@ -163,7 +162,6 @@ def test_all_builtin_extension_commands_use_native_command_schemas(): "provider_set_stt", "provider_set_tts", "task_stop", - "work", "variable_set", "variable_unset", "flow_enable", @@ -985,7 +983,6 @@ def test_non_public_builtin_commands_declare_the_planned_actions(): "bot_disable": "session.manage", "bot_leave": "session.manage", "task_stop": "session.manage", - "work": "session.read", "conversation_create": "session.manage", "conversation_stats": "session.read", "conversation_history": "session.read", @@ -1035,20 +1032,6 @@ def test_normalized_builtin_paths_resolve_and_legacy_subcommands_do_not(): engine.resolve("flow on") assert flow_legacy.value.diagnostic.code is CommandErrorCode.UNKNOWN_SUBCOMMAND - work_task = engine.resolve("work 帮我重构这个文件") - assert work_task.resolution.kind is CommandResolutionKind.MATCHED - assert work_task.resolution.command_path == ("work",) - work_entry = work_task.resolution.entries[0] - assert dict(engine.bind(work_entry, work_task).values) == { - "task": "帮我重构这个文件" - } - - work_status = engine.resolve("work status") - assert work_status.resolution.kind is CommandResolutionKind.MATCHED - assert work_status.resolution.command_path == ("work",) - status_entry = work_status.resolution.entries[0] - assert dict(engine.bind(status_entry, work_status).values) == {"task": "status"} - class DummyProvider: def __init__(self) -> None: @@ -1137,164 +1120,3 @@ async def set_provider(**kwargs): await command.set_model(switch_event, "2") assert provider.model == "model-b" assert "Switched model." in _plain_text(switch_event.result) - - -@pytest.mark.asyncio -async def test_work_status_reports_none_and_latest(monkeypatch): - from astrbot.builtin_stars.builtin_commands.commands.work import WorkCommands - from astrbot.core.agent.btw import ( - WorkSessionManager, - WorkSessionStatus, - runtime_registry, - ) - - context = SimpleNamespace( - i18n=FakeI18n(), - config=SimpleNamespace( - get=lambda umo=None: { - "btw": {"enabled": True, "work_loop": {"enabled": True}} - } - ), - ) - command = WorkCommands(context) - - monkeypatch.setattr(runtime_registry, "_managers", {}) - event = DummyEvent(message_str="work status") - await command.status(event) - assert _plain_text(event.result) == "No BTW work task has run in this session." - - sessions = WorkSessionManager() - session = await sessions.create("napcat:FriendMessage:42", "refactor the module") - await sessions.update_status(session.id, WorkSessionStatus.RUNNING) - monkeypatch.setattr(runtime_registry, "_managers", {"": sessions}) - latest_event = DummyEvent(message_str="work status") - await command.status(latest_event) - text = _plain_text(latest_event.result) - assert "Running" in text and "refactor the module" in text - assert latest_event.result.is_stopped() - - -@pytest.mark.asyncio -async def test_work_handle_submits_free_text_and_rejects_when_disabled(): - from astrbot.builtin_stars.builtin_commands.commands.work import WorkCommands - - enabled = WorkCommands( - SimpleNamespace( - i18n=FakeI18n(), - config=SimpleNamespace( - get=lambda umo=None: { - "btw": {"enabled": True, "work_loop": {"enabled": True}} - } - ), - ) - ) - event = DummyEvent(message_str="work 帮我重构这个文件") - await enabled.handle(event, "帮我重构这个文件") - assert event.message_str == "帮我重构这个文件" - assert event.get_extra("should_run_llm") is True - assert event.get_extra("btw_force_work") is True - assert event.get_extra("btw_loop") == "work" - assert event.result is None - assert event.is_stopped() is False - - status_event = DummyEvent(message_str="work status") - await enabled.handle(status_event, "status") - assert ( - _plain_text(status_event.result) == "No BTW work task has run in this session." - ) - - upper_status = DummyEvent(message_str="work STATUS") - await enabled.handle(upper_status, "STATUS") - assert ( - _plain_text(upper_status.result) == "No BTW work task has run in this session." - ) - - status_task = DummyEvent(message_str="work status 重构") - await enabled.handle(status_task, "status 重构") - assert status_task.message_str == "status 重构" - assert status_task.get_extra("btw_force_work") is True - assert status_task.result is None - - disabled = WorkCommands( - SimpleNamespace( - i18n=FakeI18n(), - config=SimpleNamespace( - get=lambda umo=None: { - "btw": {"enabled": True, "work_loop": {"enabled": False}} - } - ), - ) - ) - blocked = DummyEvent(message_str="work 写一段 python") - await disabled.handle(blocked, "写一段 python") - assert _plain_text(blocked.result) == "The BTW work loop is not enabled." - assert blocked.get_extra("btw_force_work") is None - - -@pytest.mark.asyncio -async def test_work_handle_rejects_invalid_or_master_disabled_btw_config(): - from astrbot.builtin_stars.builtin_commands.commands.work import WorkCommands - - cases = ( - {"btw": {"enabled": False, "work_loop": {"enabled": True}}}, - {"btw": "yes"}, - None, - ) - for config in cases: - command = WorkCommands( - SimpleNamespace( - i18n=FakeI18n(), - config=SimpleNamespace(get=lambda umo=None, value=config: value), - ) - ) - event = DummyEvent(message_str="work 写一段 python") - await command.handle(event, "写一段 python") - assert _plain_text(event.result) == "The BTW work loop is not enabled." - assert event.get_extra("btw_force_work") is None - - -@pytest.mark.asyncio -async def test_work_submit_continues_into_conversation_work_loop(): - from astrbot.builtin_stars.builtin_commands.commands.work import WorkCommands - from astrbot.core.agent.conversation_loop import ConversationLoop - - class FakeAgentRequest: - def __init__(self) -> None: - self.initialize = AsyncMock() - self.process_calls = [] - - async def process(self, event): - self.process_calls.append(event) - yield "first" - - command = WorkCommands( - SimpleNamespace( - i18n=FakeI18n(), - config=SimpleNamespace( - get=lambda umo=None: { - "btw": {"enabled": True, "work_loop": {"enabled": True}} - } - ), - ) - ) - event = DummyEvent(message_str="work 帮我写一段python代码获取当前系统磁盘占用情况") - await command.handle(event, "帮我写一段python代码获取当前系统磁盘占用情况") - - loop = ConversationLoop(FakeAgentRequest()) - await loop.initialize( - SimpleNamespace( - astrbot_config={ - "btw": { - "enabled": True, - "classifier": {"enabled": False}, - "work_loop": {"enabled": True, "max_concurrent": 2}, - } - } - ) - ) - output = [item async for item in loop.process(event)] - - assert event.message_str == "帮我写一段python代码获取当前系统磁盘占用情况" - assert event.get_extra("btw_force_work") is True - assert event.get_extra("btw_loop") == "work" - assert output == ["first"] diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index f05589e371..9d1a9829cb 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -77,40 +77,6 @@ def test_default_config_avoids_public_listener_addresses(): assert "0.0.0.0" not in values -def test_btw_capability_route_assignments_survive_config_integrity(temp_config_path): - default_config = { - "btw": {"plugin_routes": [], "mcp_routes": [], "skill_routes": []} - } - with open(temp_config_path, "w", encoding="utf-8-sig") as file: - json.dump( - { - "btw": { - "plugin_routes": [ - {"plugin_id": "example", "loop": "both"}, - ], - "mcp_routes": [ - {"server_name": "workspace", "loop": "work"}, - ], - "skill_routes": [ - {"skill_name": "workspace-edit", "loop": "work"}, - ], - } - }, - file, - ) - - config = AstrBotConfig( - config_path=temp_config_path, - default_config=default_config, - ) - - assert config["btw"]["plugin_routes"] == [{"plugin_id": "example", "loop": "both"}] - assert config["btw"]["mcp_routes"] == [{"server_name": "workspace", "loop": "work"}] - assert config["btw"]["skill_routes"] == [ - {"skill_name": "workspace-edit", "loop": "work"} - ] - - def test_default_config_omits_group_active_reply(): assert "active_reply" not in DEFAULT_CONFIG["provider_ltm_settings"] diff --git a/tests/unit/test_conversation_loop.py b/tests/unit/test_conversation_loop.py deleted file mode 100644 index 3b5af7f24c..0000000000 --- a/tests/unit/test_conversation_loop.py +++ /dev/null @@ -1,181 +0,0 @@ -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest - -from astrbot.core.agent.btw import WorkSessionManager, WorkSessionStatus -from astrbot.core.agent.conversation_loop import ConversationLoop - - -class FakeAgentRequest: - def __init__(self) -> None: - self.initialize = AsyncMock() - self.process_calls = [] - - async def process(self, event): - self.process_calls.append(event) - yield "first" - yield "second" - - -class FakeEvent: - def __init__(self, message: str = "hello") -> None: - self.unified_msg_origin = "umo-1" - self.message_str = message - self.extras = {} - self.result = None - - def set_extra(self, key, value) -> None: - self.extras[key] = value - - def get_extra(self, key): - return self.extras.get(key) - - def set_result(self, value) -> None: - self.result = value - - -def _ctx() -> SimpleNamespace: - return SimpleNamespace( - astrbot_config={ - "btw": { - "enabled": True, - "classifier": {"enabled": True}, - "work_loop": {"enabled": True, "max_concurrent": 2}, - } - } - ) - - -@pytest.mark.asyncio -async def test_conversation_loop_initializes_the_current_agent_request_path(): - loop = ConversationLoop(FakeAgentRequest()) - ctx = _ctx() - - await loop.initialize(ctx) - - loop.agent_request.initialize.assert_awaited_once_with(ctx) - assert loop.work_loop is not None - - -@pytest.mark.asyncio -async def test_conversation_loop_forwards_simple_chat_to_agent_request(): - loop = ConversationLoop(FakeAgentRequest()) - await loop.initialize(_ctx()) - event = FakeEvent() - - output = [item async for item in loop.process(event)] - - assert output == ["first", "second"] - assert loop.agent_request.process_calls == [event] - assert event.get_extra("btw_loop") == "conversation" - - -@pytest.mark.asyncio -async def test_conversation_loop_honors_forced_work_without_classifier_keywords(): - loop = ConversationLoop(FakeAgentRequest()) - await loop.initialize(_ctx()) - event = FakeEvent("帮我写一段python代码获取当前系统磁盘占用情况") - event.set_extra("btw_force_work", True) - - output = [item async for item in loop.process(event)] - - assert output == ["first", "second"] - assert event.get_extra("btw_loop") == "work" - - -@pytest.mark.asyncio -async def test_conversation_loop_ignores_forced_work_when_btw_disabled(): - loop = ConversationLoop(FakeAgentRequest()) - await loop.initialize( - SimpleNamespace( - astrbot_config={ - "btw": {"enabled": False, "work_loop": {"enabled": True}}, - } - ) - ) - event = FakeEvent("帮我写一段python代码获取当前系统磁盘占用情况") - event.set_extra("btw_force_work", True) - - output = [item async for item in loop.process(event)] - - assert output == ["first", "second"] - assert loop.agent_request.process_calls == [event] - assert event.get_extra("btw_loop") is None - - -@pytest.mark.asyncio -async def test_conversation_loop_ignores_forced_work_when_work_loop_disabled(): - loop = ConversationLoop(FakeAgentRequest()) - await loop.initialize( - SimpleNamespace( - astrbot_config={ - "btw": { - "enabled": True, - "classifier": {"enabled": True}, - "work_loop": {"enabled": False, "max_concurrent": 2}, - } - } - ) - ) - event = FakeEvent("帮我写一段python代码获取当前系统磁盘占用情况") - event.set_extra("btw_force_work", True) - - output = [item async for item in loop.process(event)] - - assert output == ["first", "second"] - assert event.get_extra("btw_loop") == "conversation" - - -@pytest.mark.asyncio -async def test_conversation_loop_runs_classified_work_and_completes_session(): - loop = ConversationLoop(FakeAgentRequest()) - await loop.initialize(_ctx()) - event = FakeEvent("请帮我重构这个项目") - - output = [item async for item in loop.process(event)] - - assert output == ["first", "second"] - assert event.get_extra("btw_loop") == "work" - session = await loop.work_sessions.get_for_origin(event.unified_msg_origin) - assert session is not None - assert session.status is WorkSessionStatus.COMPLETED - - -@pytest.mark.asyncio -async def test_conversation_loop_exposes_status_via_registry_command(): - """Status queries go through the /work command, not message substrings.""" - sessions = WorkSessionManager() - session = await sessions.create("umo-1", "重构项目") - await sessions.update_status(session.id, WorkSessionStatus.RUNNING) - agent_request = FakeAgentRequest() - loop = ConversationLoop(agent_request, work_sessions=sessions) - await loop.initialize(_ctx()) - loop.expose_to_commands("default") - - from astrbot.core.agent.btw import runtime_registry - - latest = await runtime_registry.latest_status("default", "umo-1") - - assert latest is not None - request, status = latest - assert request == "重构项目" - assert status is WorkSessionStatus.RUNNING - - # Status messages must reach the agent path, not be short-circuited. - event = FakeEvent("进度怎么样了?") - output = [item async for item in loop.process(event)] - assert agent_request.process_calls == [event] - assert output - - -@pytest.mark.asyncio -async def test_conversation_loop_registry_returns_none_without_sessions(): - agent_request = FakeAgentRequest() - loop = ConversationLoop(agent_request, work_sessions=WorkSessionManager()) - await loop.initialize(_ctx()) - loop.expose_to_commands("default") - - from astrbot.core.agent.btw import runtime_registry - - assert await runtime_registry.latest_status("default", "umo-unknown") is None diff --git a/tests/unit/test_process_stage.py b/tests/unit/test_process_stage.py index 8d3d7f1373..c84add9edf 100644 --- a/tests/unit/test_process_stage.py +++ b/tests/unit/test_process_stage.py @@ -148,11 +148,7 @@ def _stage( astrbot_config={"provider_settings": {"enable": provider_enabled}} ) stage.star_request_sub_stage = FakeSubStage(star_responses or []) - agent_request = FakeSubStage(agent_responses or []) - stage._agent_request = agent_request - stage.conversation_loop = None - # Keep the alias while these tests describe the previous request-path name. - stage.agent_sub_stage = agent_request + stage.agent_sub_stage = FakeSubStage(agent_responses or []) return stage @@ -215,24 +211,6 @@ async def test_process_stage_plain_plugin_response_does_not_trigger_agent(): assert stage.agent_sub_stage.calls == [] -@pytest.mark.asyncio -async def test_process_stage_command_handler_can_continue_to_agent_when_llm_requested(): - stage = _stage(star_responses=[None], agent_responses=["agent-step"]) - event = FakeEvent( - extras={ - "activated_handlers": [SimpleNamespace(name="work")], - "should_run_llm": True, - "btw_force_work": True, - } - ) - - yielded = [item async for item in stage.process(event)] - - assert yielded == [None, None] - assert stage.star_request_sub_stage.calls == [(event,)] - assert stage.agent_sub_stage.calls == [(event,)] - - @pytest.mark.asyncio async def test_process_stage_wake_path_runs_agent_without_plugin_handlers(): stage = _stage(agent_responses=["agent-step"]) From 32b32306b20fb4ca19add4f99edbe74fb0850e07 Mon Sep 17 00:00:00 2001 From: YUZHEthefool <2804776511@qq.com> Date: Fri, 11 Sep 2026 00:54:52 +0800 Subject: [PATCH 5/5] docs(btw): link feature stack and classifier experiments Replace discussion identifiers with the published Issue and PR links, record the stacked feature delivery, and clarify unfinished experiments. Refs: #122 AI-Generated: true Generated-At: 2026-09-10T16:54:52Z --- docs/en/dev/btw-dual-loop.md | 42 ++++++++++++++++++------------------ docs/zh/dev/btw-dual-loop.md | 42 ++++++++++++++++++------------------ 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/docs/en/dev/btw-dual-loop.md b/docs/en/dev/btw-dual-loop.md index ea06ccbee4..aa7243c85f 100644 --- a/docs/en/dev/btw-dual-loop.md +++ b/docs/en/dev/btw-dual-loop.md @@ -19,7 +19,7 @@ The work loop is not a new permission level, and a handoff grants no additional ## Current Decisions -- PR #28 retains this design document only. The prototype remains historical reference material; a parent Issue and Sub-issues will track discussion, implementation, and acceptance of each capability. +- PR #28 retains this design document only. The prototype remains historical reference material; the parent Issue and Sub-issues link separate PRs for implementation and acceptance of each capability. - Follow-up implementation starts from current master and reuses its message pipeline, Agent runners, tool catalog, and Skill snapshots. Superseded assembly and authorization logic from the prototype must not be copied back. - The initial direction retains explicit entry and disabled defaults: `/work ` submits work. The prototype rule classifier is a reference candidate tested in its own experimental PR, not a predetermined production default. - The intended later direction is: **routing belongs inside the conversation loop. The loop knows its available capabilities, handles requests it can fulfill, and hands requests that need work capabilities to the work loop.** @@ -29,23 +29,23 @@ The reference prototype is commit [`33ee103a62937db3e930c89ba47a648b75cc7772`](h ## Capability Breakdown -B1–B10 below are proposed feature Sub-issues; R1–R3 each correspond to a separate classifier experiment PR. These are discussion identifiers, not GitHub Issue numbers. Each slice includes its settings, Dashboard interactions, tests, and bilingual documentation instead of splitting all frontend and backend work into separate tickets. This PR neither implements nor validates the acceptance requirements in the table. - -| ID | Sub-issue scope | Acceptance focus | -| --- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| B1 | Opt-in dual-loop entry and ordinary conversation | Disabled mode preserves the current Agent path and capabilities; enabled mode admits ordinary requests to conversation; automatic-classifier experiments are not a prerequisite. | -| B2 | Explicit task entry with `/work ` | A registered built-in command receives the full task text; submission works with classification off; disabled work returns a clear message; command permissions and session LLM switches remain effective. | -| B3 | Background execution, concurrency, and lifecycle | Acknowledge receipt before background execution while conversation remains available; bound concurrency; verify pending, running, completed, failed, and cancelled states; reclaim tasks at runtime shutdown. | -| B4 | Result delivery and event resource release | Return results to the originating request through reply checks, decoration, and delivery; the acknowledgement must not prematurely end a WebChat request; release temporary files and event registrations at completion. | -| B5 | Work status and retention | `/work` and `/work status` report the latest task for the current profile and session; empty, terminal, and expired states are defined; profiles remain isolated and group-chat query scope is explained. | -| B6 | Model selection per loop | Conversation and work may choose different models; empty selections inherit the current choice; invalid or unavailable selections have defined behavior; disabling BTW preserves ordinary model selection. | -| B7 | Computer Use boundaries per loop | Conversation does not mount Computer Use; work may inherit or select `none`, `local`, or `sandbox`; handoffs retain the same restrictions and loop selection does not alter permissions. | -| B8 | Assign plugin LLM tools to loops | Select conversation, work, or both per plugin; the prototype defaults to work; main Agent and handoff behavior agree. Only LLM tools are affected, not plugin event handlers or the execution destination of explicit commands. | -| B9 | Assign MCP tools to loops by server | Tools from one MCP server follow a shared assignment; the prototype defaults to work; saved settings, main Agent, and handoff behavior agree while preserving MCP connection and authorization boundaries. | -| B10 | Skill visibility per loop | Ordinary Skills defaulted to both in the prototype; workspace Skills retain the work-loop and local-runtime boundary; prompts, `read_skill`, and declared tools use consistent filtering; reading a Skill does not require Shell permission. | -| R1 | Rule-classifier experiment PR | Use prototype keywords and deterministic rules as a reference; test boundaries, everyday queries, and mistakes when capabilities change; report results independently. | -| R2 | Separate model-classifier experiment PR | Call a model before conversation execution to choose a destination; measure classification quality and the latency and cost of the additional call without presuming this is the final architecture. | -| R3 | Capability-aware decision inside conversation, in its own PR | Let the conversation model handle or hand off requests based on available capabilities; measure unnecessary and missed handoffs and context continuity. This is the currently preferred direction to investigate. | +[Parent issue #122](https://github.com/Xero-Team/AstrBot/issues/122) tracks the following capabilities and dependencies. B1–B10 each have a non-draft feature PR; R1–R3 each have a separate draft classifier PR. B/R identifiers remain design indices, with links to the actual Issues and PRs. Each slice includes its settings, Dashboard interactions, tests, and bilingual documentation instead of splitting all frontend and backend work into separate tickets. This PR neither implements nor validates the acceptance requirements in the table. + +| Issue | PR | Sub-issue scope | Acceptance focus | +| ------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [B1 / #123](https://github.com/Xero-Team/AstrBot/issues/123) | [#136](https://github.com/Xero-Team/AstrBot/pull/136) (non-draft) | Opt-in dual-loop entry and ordinary conversation | Disabled mode preserves the current Agent path and capabilities; enabled mode admits ordinary requests to conversation; automatic-classifier experiments are not a prerequisite. | +| [B2 / #124](https://github.com/Xero-Team/AstrBot/issues/124) | [#139](https://github.com/Xero-Team/AstrBot/pull/139) (non-draft) | Explicit task entry with `/work ` | A registered built-in command receives the full task text; submission works with classification off; disabled work returns a clear message; command permissions and session LLM switches remain effective. | +| [B3 / #125](https://github.com/Xero-Team/AstrBot/issues/125) | [#137](https://github.com/Xero-Team/AstrBot/pull/137) (non-draft) | Background execution, concurrency, and lifecycle | Acknowledge receipt before background execution while conversation remains available; bound concurrency; verify pending, running, completed, failed, and cancelled states; reclaim tasks at runtime shutdown. | +| [B4 / #126](https://github.com/Xero-Team/AstrBot/issues/126) | [#138](https://github.com/Xero-Team/AstrBot/pull/138) (non-draft) | Result delivery and event resource release | Return results to the originating request through reply checks, decoration, and delivery; the acknowledgement must not prematurely end a WebChat request; release temporary files and event registrations at completion. | +| [B5 / #127](https://github.com/Xero-Team/AstrBot/issues/127) | [#140](https://github.com/Xero-Team/AstrBot/pull/140) (non-draft) | Work status and retention | `/work` and `/work status` report the latest task for the current profile and session; empty, terminal, and expired states are defined; profiles remain isolated and group-chat query scope is explained. | +| [B6 / #128](https://github.com/Xero-Team/AstrBot/issues/128) | [#141](https://github.com/Xero-Team/AstrBot/pull/141) (non-draft) | Model selection per loop | Conversation and work may choose different models; empty selections inherit the current choice; invalid or unavailable selections have defined behavior; disabling BTW preserves ordinary model selection. | +| [B7 / #129](https://github.com/Xero-Team/AstrBot/issues/129) | [#142](https://github.com/Xero-Team/AstrBot/pull/142) (non-draft) | Computer Use boundaries per loop | Conversation does not mount Computer Use; work may inherit or select `none`, `local`, or `sandbox`; handoffs retain the same restrictions and loop selection does not alter permissions. | +| [B8 / #130](https://github.com/Xero-Team/AstrBot/issues/130) | [#143](https://github.com/Xero-Team/AstrBot/pull/143) (non-draft) | Assign plugin LLM tools to loops | Select conversation, work, or both per plugin; the prototype defaults to work; main Agent and handoff behavior agree. Only LLM tools are affected, not plugin event handlers or the execution destination of explicit commands. | +| [B9 / #131](https://github.com/Xero-Team/AstrBot/issues/131) | [#144](https://github.com/Xero-Team/AstrBot/pull/144) (non-draft) | Assign MCP tools to loops by server | Tools from one MCP server follow a shared assignment; the prototype defaults to work; saved settings, main Agent, and handoff behavior agree while preserving MCP connection and authorization boundaries. | +| [B10 / #132](https://github.com/Xero-Team/AstrBot/issues/132) | [#145](https://github.com/Xero-Team/AstrBot/pull/145) (non-draft) | Skill visibility per loop | Ordinary Skills defaulted to both in the prototype; workspace Skills retain the work-loop and local-runtime boundary; prompts, `read_skill`, and declared tools use consistent filtering; reading a Skill does not require Shell permission. | +| [R1 / #133](https://github.com/Xero-Team/AstrBot/issues/133) | [#146](https://github.com/Xero-Team/AstrBot/pull/146) (draft) | Rule-classifier experiment PR | Use prototype keywords and deterministic rules as a reference; test boundaries, everyday queries, and mistakes when capabilities change; report results independently. | +| [R2 / #134](https://github.com/Xero-Team/AstrBot/issues/134) | [#147](https://github.com/Xero-Team/AstrBot/pull/147) (draft) | Separate model-classifier experiment PR | Call a model before conversation execution to choose a destination; measure classification quality and the latency and cost of the additional call without presuming this is the final architecture. | +| [R3 / #135](https://github.com/Xero-Team/AstrBot/issues/135) | [#148](https://github.com/Xero-Team/AstrBot/pull/148) (draft) | Capability-aware decision inside conversation, in its own PR | Let the conversation model handle or hand off requests based on available capabilities; measure unnecessary and missed handoffs and context continuity. This is the currently preferred direction to investigate. | ### Dependencies and Delivery Order @@ -59,7 +59,7 @@ R1–R3 are parallel alternatives with no dependency on one another; they are no ### Separate PRs and a Shared Evaluation Protocol -R1–R3 are initial candidates whose scope can be refined in their Sub-issues. Each PR contains one classifier implementation and its tests, recording the baseline commit, dataset version, capability fixtures, model, and parameters. Establish common cases and the evaluation harness first so implementations do not select different datasets to demonstrate success. +R1–R3 are initial candidates whose scope can be refined in their Sub-issues. Each completed experiment is expected to contain one classifier implementation and its tests, recording the baseline commit, dataset version, capability fixtures, model, and parameters. R2/R3 currently contain experiment plans only. Establish common cases and the evaluation harness first so implementations do not select different datasets to demonstrate success. Run the same offline cases in each PR, followed by the same integration scenarios on a shared working dual-loop baseline. Deterministic tests check contracts; model trials separately report variation across repeated runs. Report both forms of evidence separately. Explicit `/work` entry is a control baseline for every approach, not a fourth classifier. @@ -98,8 +98,8 @@ Measure unnecessary and missed handoffs, task completion, appropriate clarificat ## Parent Issue and Non-goals -The proposed parent Issue, “BTW dual-loop: capability breakdown and classifier comparison,” tracks B1–B10, dependencies, acceptance, and the separate R1–R3 experiment PRs and results. Product integration of classification remains deferred. Create the GitHub parent and native Sub-issues after discussing the slices, then replace these discussion identifiers with real links. +[Parent issue #122](https://github.com/Xero-Team/AstrBot/issues/122) uses native Sub-issues to track B1–B10, dependencies, acceptance, and the R1–R3 experiment PRs and results. Feature PRs are stacked by dependency so each diff contains one capability. The three classifier drafts use the same feature baseline and are tested separately. R1 extracts the prototype's existing rules; the prototype contains no R2/R3 model classifier implementation, so those drafts initially provide experiment plans and explicit outstanding validation. Product integration of classification remains deferred. This increment excludes persistent or resumable work, cross-device scheduling, an automatic retry or rollback platform, a multi-task management page, and enabling automatic routing by default in production. The prototype's `pyupgrade` adjustment is separate repository maintenance, outside the BTW feature scope. -Current behavior is documented in [Architecture](./architecture.md), [Computer Use](../use/computer.md), and [Skills](../use/skills.md). The directions and acceptance requirements here need agreement in follow-up Issues and do not replace implementation review. +Current behavior is documented in [Architecture](./architecture.md), [Computer Use](../use/computer.md), and [Skills](../use/skills.md). Implementation and acceptance are tracked in the linked Issues and PRs; this overview does not replace implementation review. diff --git a/docs/zh/dev/btw-dual-loop.md b/docs/zh/dev/btw-dual-loop.md index 02789cadcc..f8af0d3f8d 100644 --- a/docs/zh/dev/btw-dual-loop.md +++ b/docs/zh/dev/btw-dual-loop.md @@ -19,7 +19,7 @@ BTW 希望让用户在较长的工作任务执行期间继续对话,并能查 ## 当前决策 -- PR #28 只保留这份设计说明,原型代码作为历史参考,功能通过总 Issue 和 Sub-issues 逐项讨论、实现和验收。 +- PR #28 只保留这份设计说明,原型代码作为历史参考;总 Issue 与 Sub-issues 已关联独立 PR,分别跟踪各项能力的实现与验收。 - 后续实现从当前主干出发,复用现有消息流水线、Agent runner、工具目录和 Skill 快照;不直接搬回原型中已经过时的装配或授权逻辑。 - 初期保持显式入口与默认关闭原则:通过 `/work ` 提交工作。原型规则分类器保留为对照方案,在独立实验 PR 中测试,不预先选为产品默认路由。 - 期望的后续方向是:**路由判断并入对话循环;对话循环知道自身实际可用的能力,能完成的自行处理,需要工作能力的再转交工作循环。** @@ -29,23 +29,23 @@ BTW 希望让用户在较长的工作任务执行期间继续对话,并能查 ## 功能拆分 -以下 B1–B10 是拟议的功能子 Issue,R1–R3 分别对应独立的分类器实验 PR;编号仅用于本文讨论,并非 GitHub Issue 编号。每项同时包含相应配置、Dashboard 交互、测试与中英文说明,不另拆一组纯前端或纯后端任务。表中的验收要求尚未在本 PR 中实现或验证。 - -| 编号 | 子 Issue 范围 | 独立验收重点 | -| ---- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| B1 | 默认关闭的双循环接入与普通对话 | 关闭时保持当前 Agent 路径和能力;开启后普通请求进入对话循环;自动分类器实验不成为本项的前置条件。 | -| B2 | 显式工作任务入口 `/work ` | 使用正式内置命令身份接收完整任务文本;分类器关闭仍可提交;工作关闭时给出明确提示;遵守命令权限和会话 LLM 开关。 | -| B3 | 后台工作执行、并发与生命周期 | 先确认接收,再后台执行,期间可以继续对话;限制并发;排队、运行、完成、失败、取消状态可验证;关闭运行时会收回任务。 | -| B4 | 工作结果回送与事件资源释放 | 结果回到原请求,经过回复内容检查、装饰和发送;确认消息不提前结束 WebChat 请求;最终清理临时文件和事件注册。 | -| B5 | 工作状态查询与保留 | `/work` 与 `/work status` 查询当前配置、当前会话的最新任务;空状态、终态和过期行为明确;配置之间不串状态,界面说明群聊查询范围。 | -| B6 | 每个循环独立选择模型 | 对话与工作可选择不同模型,空值继承当前选择;选错或不可用时的行为明确;关闭 BTW 不改变普通模型选择。 | -| B7 | 每个循环的 Computer Use 边界 | 对话循环不挂载 Computer Use;工作循环可继承或选择 `none`、`local`、`sandbox`;handoff 保持相同限制,循环选择不改变权限。 | -| B8 | 插件 LLM 工具按循环分配 | 按插件配置对话、工作或两者;原型默认工作;主 Agent 与 handoff 一致。只影响 LLM 工具,不改变插件事件处理器或显式命令的执行归属。 | -| B9 | MCP 工具按服务器分配循环 | 同一 MCP 服务器的工具遵循统一分配;原型默认工作;配置保存、主 Agent 与 handoff 一致,沿用 MCP 连接和授权边界。 | -| B10 | Skill 按循环可见 | 普通 Skill 原型默认两者;工作区 Skill 保留工作循环与本地运行时边界;提示词、`read_skill` 和声明工具使用一致的筛选结果;读取 Skill 不要求 Shell 权限。 | -| R1 | 规则分类器实验 PR | 以原型关键词和确定性规则为对照,验证词边界、日常查询和能力变化时的误判;独立记录结果。 | -| R2 | 独立模型分类器实验 PR | 在对话执行前单独调用模型判断去向,测量分类效果及额外调用的时延、成本;不预设它是最终架构。 | -| R3 | 对话循环内能力感知实验 PR | 对话模型基于当前可用能力自行处理或转交,验证误转交、遗漏和上下文延续;这是当前倾向重点验证的方向。 | +[总 Issue #122](https://github.com/Xero-Team/AstrBot/issues/122) 汇总以下功能与依赖。B1–B10 各有一个普通功能 PR;R1–R3 分别对应独立的分类器草稿 PR。表中的 B/R 标识保留为设计索引,链接指向真实 Issue 与 PR。每项同时包含相应配置、Dashboard 交互、测试与中英文说明,不另拆一组纯前端或纯后端任务。表中的验收要求尚未在本 PR 中实现或验证。 + +| Issue | PR | 子 Issue 范围 | 独立验收重点 | +| ------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| [B1 / #123](https://github.com/Xero-Team/AstrBot/issues/123) | [#136](https://github.com/Xero-Team/AstrBot/pull/136) (普通 PR) | 默认关闭的双循环接入与普通对话 | 关闭时保持当前 Agent 路径和能力;开启后普通请求进入对话循环;自动分类器实验不成为本项的前置条件。 | +| [B2 / #124](https://github.com/Xero-Team/AstrBot/issues/124) | [#139](https://github.com/Xero-Team/AstrBot/pull/139) (普通 PR) | 显式工作任务入口 `/work ` | 使用正式内置命令身份接收完整任务文本;分类器关闭仍可提交;工作关闭时给出明确提示;遵守命令权限和会话 LLM 开关。 | +| [B3 / #125](https://github.com/Xero-Team/AstrBot/issues/125) | [#137](https://github.com/Xero-Team/AstrBot/pull/137) (普通 PR) | 后台工作执行、并发与生命周期 | 先确认接收,再后台执行,期间可以继续对话;限制并发;排队、运行、完成、失败、取消状态可验证;关闭运行时会收回任务。 | +| [B4 / #126](https://github.com/Xero-Team/AstrBot/issues/126) | [#138](https://github.com/Xero-Team/AstrBot/pull/138) (普通 PR) | 工作结果回送与事件资源释放 | 结果回到原请求,经过回复内容检查、装饰和发送;确认消息不提前结束 WebChat 请求;最终清理临时文件和事件注册。 | +| [B5 / #127](https://github.com/Xero-Team/AstrBot/issues/127) | [#140](https://github.com/Xero-Team/AstrBot/pull/140) (普通 PR) | 工作状态查询与保留 | `/work` 与 `/work status` 查询当前配置、当前会话的最新任务;空状态、终态和过期行为明确;配置之间不串状态,界面说明群聊查询范围。 | +| [B6 / #128](https://github.com/Xero-Team/AstrBot/issues/128) | [#141](https://github.com/Xero-Team/AstrBot/pull/141) (普通 PR) | 每个循环独立选择模型 | 对话与工作可选择不同模型,空值继承当前选择;选错或不可用时的行为明确;关闭 BTW 不改变普通模型选择。 | +| [B7 / #129](https://github.com/Xero-Team/AstrBot/issues/129) | [#142](https://github.com/Xero-Team/AstrBot/pull/142) (普通 PR) | 每个循环的 Computer Use 边界 | 对话循环不挂载 Computer Use;工作循环可继承或选择 `none`、`local`、`sandbox`;handoff 保持相同限制,循环选择不改变权限。 | +| [B8 / #130](https://github.com/Xero-Team/AstrBot/issues/130) | [#143](https://github.com/Xero-Team/AstrBot/pull/143) (普通 PR) | 插件 LLM 工具按循环分配 | 按插件配置对话、工作或两者;原型默认工作;主 Agent 与 handoff 一致。只影响 LLM 工具,不改变插件事件处理器或显式命令的执行归属。 | +| [B9 / #131](https://github.com/Xero-Team/AstrBot/issues/131) | [#144](https://github.com/Xero-Team/AstrBot/pull/144) (普通 PR) | MCP 工具按服务器分配循环 | 同一 MCP 服务器的工具遵循统一分配;原型默认工作;配置保存、主 Agent 与 handoff 一致,沿用 MCP 连接和授权边界。 | +| [B10 / #132](https://github.com/Xero-Team/AstrBot/issues/132) | [#145](https://github.com/Xero-Team/AstrBot/pull/145) (普通 PR) | Skill 按循环可见 | 普通 Skill 原型默认两者;工作区 Skill 保留工作循环与本地运行时边界;提示词、`read_skill` 和声明工具使用一致的筛选结果;读取 Skill 不要求 Shell 权限。 | +| [R1 / #133](https://github.com/Xero-Team/AstrBot/issues/133) | [#146](https://github.com/Xero-Team/AstrBot/pull/146) (草稿) | 规则分类器实验 PR | 以原型关键词和确定性规则为对照,验证词边界、日常查询和能力变化时的误判;独立记录结果。 | +| [R2 / #134](https://github.com/Xero-Team/AstrBot/issues/134) | [#147](https://github.com/Xero-Team/AstrBot/pull/147) (草稿) | 独立模型分类器实验 PR | 在对话执行前单独调用模型判断去向,测量分类效果及额外调用的时延、成本;不预设它是最终架构。 | +| [R3 / #135](https://github.com/Xero-Team/AstrBot/issues/135) | [#148](https://github.com/Xero-Team/AstrBot/pull/148) (草稿) | 对话循环内能力感知实验 PR | 对话模型基于当前可用能力自行处理或转交,验证误转交、遗漏和上下文延续;这是当前倾向重点验证的方向。 | ### 依赖与交付顺序 @@ -59,7 +59,7 @@ R1–R3 是平行方案,互不依赖,不串成前一个分类器改完再开 ### 独立 PR 与共同评估约定 -R1–R3 是首轮候选拆法,可在各子 Issue 中调整。每个 PR 只承载一种分类器实现及其测试,写明基线提交、样例版本、能力集合、模型与参数。共同样例和评估脚手架应先固定,避免每个实现选择不同的数据证明自己有效。 +R1–R3 是首轮候选拆法,可在各子 Issue 中调整。每项完成的实验应包含一种分类器实现及其测试,写明基线提交、样例版本、能力集合、模型与参数。R2/R3 目前仅包含实验方案。共同样例和评估脚手架应先固定,避免每个实现选择不同的数据证明自己有效。 各 PR 分别执行相同的离线样例,随后在共同的可用双循环基线上执行相同的集成场景。确定性测试用于验证契约,模型试验另行记录重复运行的波动;两类结果分别报告。显式 `/work` 是所有方案的控制基线,不作为第四种分类器。 @@ -98,8 +98,8 @@ R1–R3 是首轮候选拆法,可在各子 Issue 中调整。每个 PR 只承 ## 总 Issue 与非目标 -拟议总 Issue 为“BTW 双循环:功能拆分与分类器对比”,负责汇总 B1–B10 的进度、依赖和验收,以及 R1–R3 各自的实验 PR 和结果。分类器产品接入暂缓。待逐项讨论后创建 GitHub 总 Issue 与原生 Sub-issues;届时用真实链接替换本文的讨论编号。 +[总 Issue #122](https://github.com/Xero-Team/AstrBot/issues/122) 通过原生 Sub-issues 汇总 B1–B10 的进度、依赖和验收,以及 R1–R3 的实验 PR 与结果。功能 PR 按依赖叠放,每个 PR 的差异仅包含本项功能;三个分类器草稿使用同一功能基线,分别测试。R1 提取原型已有规则实现;原型没有 R2/R3 的模型分类器实现,因此这两个草稿先承载实验方案与未完成的验证要求。分类器产品接入仍暂缓。 本轮不包含工作任务持久化与断点续跑、跨设备工作调度、自动重试或回滚平台、多任务管理页面,以及生产环境默认开启自动路由。原型中的 `pyupgrade` 调整属于独立仓库维护,也不属于本文的 BTW 功能范围。 -当前运行行为仍以[项目架构](./architecture.md)、[Computer Use](../use/computer.md)与 [Skills](../use/skills.md) 为准。本文中的设计方向和验收条件需要在后续 Issue 中达成共识,不替代实现评审。 +当前运行行为仍以[项目架构](./architecture.md)、[Computer Use](../use/computer.md)与 [Skills](../use/skills.md) 为准。实现与验收通过已关联的 Issue 和 PR 跟踪;本文不替代实现评审。